From 6db535a34086c978301ce6e2c6d48ae7c4e60d35 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:01:00 -0700 Subject: [PATCH 01/56] wip: Web recorder --- apps/desktop/src/routes/editor/Player.tsx | 822 +++--- .../src/routes/editor/Timeline/ClipTrack.tsx | 1532 +++++----- apps/web/app/(org)/dashboard/caps/Caps.tsx | 2 + .../caps/components/EmptyCapState.tsx | 5 +- .../caps/components/UploadCapButton.tsx | 27 +- .../WebRecorderDialog/CameraPreviewWindow.tsx | 311 +++ .../WebRecorderDialog/CameraSelector.tsx | 139 + .../WebRecorderDialog/MicrophoneSelector.tsx | 141 + .../WebRecorderDialog/RecordingButton.tsx | 40 + .../RecordingModeSelector.tsx | 88 + .../WebRecorderDialog/WebRecorderDialog.tsx | 287 ++ .../WebRecorderDialogHeader.tsx | 98 + .../WebRecorderDialog/useCameraDevices.ts | 56 + .../WebRecorderDialog/useMediaPermission.ts | 106 + .../WebRecorderDialog/useMicrophoneDevices.ts | 56 + .../WebRecorderDialog/useWebRecorder.ts | 766 +++++ .../web-recorder-constants.ts | 32 + .../WebRecorderDialog/web-recorder-types.ts | 15 + .../(org)/dashboard/caps/components/index.ts | 1 + .../caps/components/sendProgressUpdate.ts | 23 + packages/web-backend/src/Videos/VideosRpcs.ts | 121 +- packages/web-backend/src/Videos/index.ts | 176 +- packages/web-domain/src/Video.ts | 44 + pnpm-lock.yaml | 2470 ++--------------- 24 files changed, 3827 insertions(+), 3531 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useCameraDevices.ts create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMediaPermission.ts create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMicrophoneDevices.ts create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-constants.ts create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-types.ts create mode 100644 apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts diff --git a/apps/desktop/src/routes/editor/Player.tsx b/apps/desktop/src/routes/editor/Player.tsx index 79f8820ae2..c97e23c6a8 100644 --- a/apps/desktop/src/routes/editor/Player.tsx +++ b/apps/desktop/src/routes/editor/Player.tsx @@ -13,425 +13,425 @@ import { useEditorShortcuts } from "./useEditorShortcuts"; import { formatTime } from "./utils"; export function Player() { - const { - project, - editorInstance, - setDialog, - totalDuration, - editorState, - setEditorState, - zoomOutLimit, - setProject, - } = useEditorContext(); - - // Load captions on mount - onMount(async () => { - if (editorInstance && editorInstance.path) { - // Still load captions into the store since they will be used by the GPU renderer - await captionsStore.loadCaptions(editorInstance.path); - - // Synchronize captions settings with project configuration - // This ensures the GPU renderer will receive the caption settings - if (editorInstance && project) { - const updatedProject = { ...project }; - - // Add captions data to project configuration if it doesn't exist - if ( - !updatedProject.captions && - captionsStore.state.segments.length > 0 - ) { - updatedProject.captions = { - segments: captionsStore.state.segments.map((segment) => ({ - id: segment.id, - start: segment.start, - end: segment.end, - text: segment.text, - })), - settings: { - enabled: captionsStore.state.settings.enabled, - font: captionsStore.state.settings.font, - size: captionsStore.state.settings.size, - color: captionsStore.state.settings.color, - backgroundColor: captionsStore.state.settings.backgroundColor, - backgroundOpacity: captionsStore.state.settings.backgroundOpacity, - position: captionsStore.state.settings.position, - bold: captionsStore.state.settings.bold, - italic: captionsStore.state.settings.italic, - outline: captionsStore.state.settings.outline, - outlineColor: captionsStore.state.settings.outlineColor, - exportWithSubtitles: - captionsStore.state.settings.exportWithSubtitles, - }, - }; - - // Update the project with captions data - setProject(updatedProject); - - // Save the updated project configuration - await commands.setProjectConfig(updatedProject); - } - } - } - }); - - // Continue to update current caption when playback time changes - // This is still needed for CaptionsTab to highlight the current caption - createEffect(() => { - const time = editorState.playbackTime; - // Only update captions if we have a valid time and segments exist - if ( - time !== undefined && - time >= 0 && - captionsStore.state.segments.length > 0 - ) { - captionsStore.updateCurrentCaption(time); - } - }); - - const [canvasContainerRef, setCanvasContainerRef] = - createSignal(); - const containerBounds = createElementBounds(canvasContainerRef); - - const isAtEnd = () => { - const total = totalDuration(); - return total > 0 && total - editorState.playbackTime <= 0.1; - }; - - const cropDialogHandler = () => { - const display = editorInstance.recordings.segments[0].display; - setDialog({ - open: true, - type: "crop", - position: { - ...(project.background.crop?.position ?? { x: 0, y: 0 }), - }, - size: { - ...(project.background.crop?.size ?? { - x: display.width, - y: display.height, - }), - }, - }); - }; - - createEffect(() => { - if (isAtEnd() && editorState.playing) { - commands.stopPlayback(); - setEditorState("playing", false); - } - }); - - const handlePlayPauseClick = async () => { - try { - if (isAtEnd()) { - await commands.stopPlayback(); - setEditorState("playbackTime", 0); - await commands.seekTo(0); - await commands.startPlayback(FPS, OUTPUT_SIZE); - setEditorState("playing", true); - } else if (editorState.playing) { - await commands.stopPlayback(); - setEditorState("playing", false); - } else { - // Ensure we seek to the current playback time before starting playback - await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); - await commands.startPlayback(FPS, OUTPUT_SIZE); - setEditorState("playing", true); - } - if (editorState.playing) setEditorState("previewTime", null); - } catch (error) { - console.error("Error handling play/pause:", error); - setEditorState("playing", false); - } - }; - - // Register keyboard shortcuts in one place - useEditorShortcuts(() => { - const el = document.activeElement; - if (!el) return true; - const tagName = el.tagName.toLowerCase(); - const isContentEditable = el.getAttribute("contenteditable") === "true"; - return !( - tagName === "input" || - tagName === "textarea" || - isContentEditable - ); - }, [ - { - combo: "S", - handler: () => - setEditorState( - "timeline", - "interactMode", - editorState.timeline.interactMode === "split" ? "seek" : "split", - ), - }, - { - combo: "Mod+=", - handler: () => - editorState.timeline.transform.updateZoom( - editorState.timeline.transform.zoom / 1.1, - editorState.playbackTime, - ), - }, - { - combo: "Mod+-", - handler: () => - editorState.timeline.transform.updateZoom( - editorState.timeline.transform.zoom * 1.1, - editorState.playbackTime, - ), - }, - { - combo: "Space", - handler: async () => { - const prevTime = editorState.previewTime; - - if (!editorState.playing) { - if (prevTime !== null) setEditorState("playbackTime", prevTime); - - await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); - } - - await handlePlayPauseClick(); - }, - }, - ]); - - return ( -
-
- - cropDialogHandler()} - leftIcon={} - > - Crop - -
- -
-
-
-
- - - - - -
-
-
- - tooltipText="Toggle Split" - kbd={["S"]} - pressed={editorState.timeline.interactMode === "split"} - onChange={(v: boolean) => - setEditorState("timeline", "interactMode", v ? "split" : "seek") - } - as={KToggleButton} - variant="danger" - leftIcon={ - - } - /> -
- - { - editorState.timeline.transform.updateZoom( - editorState.timeline.transform.zoom * 1.1, - editorState.playbackTime, - ); - }} - class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70" - /> - - - { - editorState.timeline.transform.updateZoom( - editorState.timeline.transform.zoom / 1.1, - editorState.playbackTime, - ); - }} - class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70" - /> - - { - editorState.timeline.transform.updateZoom( - (1 - v) * zoomOutLimit(), - editorState.playbackTime, - ); - }} - formatTooltip={() => - `${editorState.timeline.transform.zoom.toFixed( - 0, - )} seconds visible` - } - /> -
-
-
- ); + const { + project, + editorInstance, + setDialog, + totalDuration, + editorState, + setEditorState, + zoomOutLimit, + setProject, + } = useEditorContext(); + + // Load captions on mount + onMount(async () => { + if (editorInstance && editorInstance.path) { + // Still load captions into the store since they will be used by the GPU renderer + await captionsStore.loadCaptions(editorInstance.path); + + // Synchronize captions settings with project configuration + // This ensures the GPU renderer will receive the caption settings + if (editorInstance && project) { + const updatedProject = { ...project }; + + // Add captions data to project configuration if it doesn't exist + if ( + !updatedProject.captions && + captionsStore.state.segments.length > 0 + ) { + updatedProject.captions = { + segments: captionsStore.state.segments.map((segment) => ({ + id: segment.id, + start: segment.start, + end: segment.end, + text: segment.text, + })), + settings: { + enabled: captionsStore.state.settings.enabled, + font: captionsStore.state.settings.font, + size: captionsStore.state.settings.size, + color: captionsStore.state.settings.color, + backgroundColor: captionsStore.state.settings.backgroundColor, + backgroundOpacity: captionsStore.state.settings.backgroundOpacity, + position: captionsStore.state.settings.position, + bold: captionsStore.state.settings.bold, + italic: captionsStore.state.settings.italic, + outline: captionsStore.state.settings.outline, + outlineColor: captionsStore.state.settings.outlineColor, + exportWithSubtitles: + captionsStore.state.settings.exportWithSubtitles, + }, + }; + + // Update the project with captions data + setProject(updatedProject); + + // Save the updated project configuration + await commands.setProjectConfig(updatedProject); + } + } + } + }); + + // Continue to update current caption when playback time changes + // This is still needed for CaptionsTab to highlight the current caption + createEffect(() => { + const time = editorState.playbackTime; + // Only update captions if we have a valid time and segments exist + if ( + time !== undefined && + time >= 0 && + captionsStore.state.segments.length > 0 + ) { + captionsStore.updateCurrentCaption(time); + } + }); + + const [canvasContainerRef, setCanvasContainerRef] = + createSignal(); + const containerBounds = createElementBounds(canvasContainerRef); + + const isAtEnd = () => { + const total = totalDuration(); + return total > 0 && total - editorState.playbackTime <= 0.1; + }; + + const cropDialogHandler = () => { + const display = editorInstance.recordings.segments[0].display; + setDialog({ + open: true, + type: "crop", + position: { + ...(project.background.crop?.position ?? { x: 0, y: 0 }), + }, + size: { + ...(project.background.crop?.size ?? { + x: display.width, + y: display.height, + }), + }, + }); + }; + + createEffect(() => { + if (isAtEnd() && editorState.playing) { + commands.stopPlayback(); + setEditorState("playing", false); + } + }); + + const handlePlayPauseClick = async () => { + try { + if (isAtEnd()) { + await commands.stopPlayback(); + setEditorState("playbackTime", 0); + await commands.seekTo(0); + await commands.startPlayback(FPS, OUTPUT_SIZE); + setEditorState("playing", true); + } else if (editorState.playing) { + await commands.stopPlayback(); + setEditorState("playing", false); + } else { + // Ensure we seek to the current playback time before starting playback + await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); + await commands.startPlayback(FPS, OUTPUT_SIZE); + setEditorState("playing", true); + } + if (editorState.playing) setEditorState("previewTime", null); + } catch (error) { + console.error("Error handling play/pause:", error); + setEditorState("playing", false); + } + }; + + // Register keyboard shortcuts in one place + useEditorShortcuts(() => { + const el = document.activeElement; + if (!el) return true; + const tagName = el.tagName.toLowerCase(); + const isContentEditable = el.getAttribute("contenteditable") === "true"; + return !( + tagName === "input" || + tagName === "textarea" || + isContentEditable + ); + }, [ + { + combo: "S", + handler: () => + setEditorState( + "timeline", + "interactMode", + editorState.timeline.interactMode === "split" ? "seek" : "split" + ), + }, + { + combo: "Mod+=", + handler: () => + editorState.timeline.transform.updateZoom( + editorState.timeline.transform.zoom / 1.1, + editorState.playbackTime + ), + }, + { + combo: "Mod+-", + handler: () => + editorState.timeline.transform.updateZoom( + editorState.timeline.transform.zoom * 1.1, + editorState.playbackTime + ), + }, + { + combo: "Space", + handler: async () => { + const prevTime = editorState.previewTime; + + if (!editorState.playing) { + if (prevTime !== null) setEditorState("playbackTime", prevTime); + + await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); + } + + await handlePlayPauseClick(); + }, + }, + ]); + + return ( +
+
+ + cropDialogHandler()} + leftIcon={} + > + Crop + +
+ +
+
+
+
+ + + + + +
+
+
+ + tooltipText="Toggle Split" + kbd={["S"]} + pressed={editorState.timeline.interactMode === "split"} + onChange={(v: boolean) => + setEditorState("timeline", "interactMode", v ? "split" : "seek") + } + as={KToggleButton} + variant="danger" + leftIcon={ + + } + /> +
+ + { + editorState.timeline.transform.updateZoom( + editorState.timeline.transform.zoom * 1.1, + editorState.playbackTime + ); + }} + class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70" + /> + + + { + editorState.timeline.transform.updateZoom( + editorState.timeline.transform.zoom / 1.1, + editorState.playbackTime + ); + }} + class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70" + /> + + { + editorState.timeline.transform.updateZoom( + (1 - v) * zoomOutLimit(), + editorState.playbackTime + ); + }} + formatTooltip={() => + `${editorState.timeline.transform.zoom.toFixed( + 0 + )} seconds visible` + } + /> +
+
+
+ ); } // CSS for checkerboard grid (adaptive to light/dark mode) const gridStyle = { - "background-image": - "linear-gradient(45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + - "linear-gradient(-45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + - "linear-gradient(45deg, transparent 75%, rgba(128,128,128,0.12) 75%), " + - "linear-gradient(-45deg, transparent 75%, rgba(128,128,128,0.12) 75%)", - "background-size": "40px 40px", - "background-position": "0 0, 0 20px, 20px -20px, -20px 0px", - "background-color": "rgba(200,200,200,0.08)", + "background-image": + "linear-gradient(45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + + "linear-gradient(-45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + + "linear-gradient(45deg, transparent 75%, rgba(128,128,128,0.12) 75%), " + + "linear-gradient(-45deg, transparent 75%, rgba(128,128,128,0.12) 75%)", + "background-size": "40px 40px", + "background-position": "0 0, 0 20px, 20px -20px, -20px 0px", + "background-color": "rgba(200,200,200,0.08)", }; function PreviewCanvas() { - const { latestFrame } = useEditorContext(); - - let canvasRef: HTMLCanvasElement | undefined; - - const [canvasContainerRef, setCanvasContainerRef] = - createSignal(); - const containerBounds = createElementBounds(canvasContainerRef); - - createEffect(() => { - const frame = latestFrame(); - if (!frame) return; - if (!canvasRef) return; - const ctx = canvasRef.getContext("2d"); - ctx?.putImageData(frame.data, 0, 0); - }); - - return ( -
- - {(currentFrame) => { - const padding = 4; - - const containerAspect = () => { - if (containerBounds.width && containerBounds.height) { - return ( - (containerBounds.width - padding * 2) / - (containerBounds.height - padding * 2) - ); - } - - return 1; - }; - - const frameAspect = () => - currentFrame().width / currentFrame().data.height; - - const size = () => { - if (frameAspect() < containerAspect()) { - const height = (containerBounds.height ?? 0) - padding * 1; - - return { - width: height * frameAspect(), - height, - }; - } - - const width = (containerBounds.width ?? 0) - padding * 2; - - return { - width, - height: width / frameAspect(), - }; - }; - - return ( -
- -
- ); - }} -
-
- ); + const { latestFrame } = useEditorContext(); + + let canvasRef: HTMLCanvasElement | undefined; + + const [canvasContainerRef, setCanvasContainerRef] = + createSignal(); + const containerBounds = createElementBounds(canvasContainerRef); + + createEffect(() => { + const frame = latestFrame(); + if (!frame) return; + if (!canvasRef) return; + const ctx = canvasRef.getContext("2d"); + ctx?.putImageData(frame.data, 0, 0); + }); + + return ( +
+ + {(currentFrame) => { + const padding = 4; + + const containerAspect = () => { + if (containerBounds.width && containerBounds.height) { + return ( + (containerBounds.width - padding * 2) / + (containerBounds.height - padding * 2) + ); + } + + return 1; + }; + + const frameAspect = () => + currentFrame().width / currentFrame().data.height; + + const size = () => { + if (frameAspect() < containerAspect()) { + const height = (containerBounds.height ?? 0) - padding * 1; + + return { + width: height * frameAspect(), + height, + }; + } + + const width = (containerBounds.width ?? 0) - padding * 2; + + return { + width, + height: width / frameAspect(), + }; + }; + + return ( +
+ +
+ ); + }} +
+
+ ); } function Time(props: { seconds: number; fps?: number; class?: string }) { - return ( - - {formatTime(props.seconds, props.fps ?? FPS)} - - ); + return ( + + {formatTime(props.seconds, props.fps ?? FPS)} + + ); } diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index 6e7c324236..51be547ac5 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -1,19 +1,19 @@ import { - createEventListener, - createEventListenerMap, + createEventListener, + createEventListenerMap, } from "@solid-primitives/event-listener"; import { cx } from "cva"; import { - type ComponentProps, - createEffect, - createMemo, - createRoot, - createSignal, - For, - Match, - onCleanup, - Show, - Switch, + type ComponentProps, + createEffect, + createMemo, + createRoot, + createSignal, + For, + Match, + onCleanup, + Show, + Switch, } from "solid-js"; import { produce } from "solid-js/store"; @@ -22,780 +22,782 @@ import { useEditorContext } from "../context"; import { useSegmentContext, useTimelineContext } from "./context"; import { getSectionMarker } from "./sectionMarker"; import { - SegmentContent, - SegmentHandle, - SegmentRoot, - TrackRoot, - useSegmentTranslateX, - useSegmentWidth, + SegmentContent, + SegmentHandle, + SegmentRoot, + TrackRoot, + useSegmentTranslateX, + useSegmentWidth, } from "./Track"; function formatTime(totalSeconds: number): string { - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = Math.floor(totalSeconds % 60); - - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; - } else if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } else { - return `${seconds}s`; - } + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = Math.floor(totalSeconds % 60); + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } else { + return `${seconds}s`; + } } function WaveformCanvas(props: { - systemWaveform?: number[]; - micWaveform?: number[]; - segment: { start: number; end: number }; - secsPerPixel: number; + systemWaveform?: number[]; + micWaveform?: number[]; + segment: { start: number; end: number }; + secsPerPixel: number; }) { - const { project } = useEditorContext(); - - let canvas: HTMLCanvasElement | undefined; - const { width } = useSegmentContext(); - const { secsPerPixel } = useTimelineContext(); - - const render = ( - ctx: CanvasRenderingContext2D, - h: number, - waveform: number[], - color: string, - gain = 0, - ) => { - const maxAmplitude = h; - - // yellow please - ctx.fillStyle = color; - ctx.beginPath(); - - const step = 0.05 / secsPerPixel(); - - ctx.moveTo(0, h); - - const norm = (w: number) => { - const ww = Number.isFinite(w) ? w : -60; - return 1.0 - Math.max(ww + gain, -60) / -60; - }; - - for ( - let segmentTime = props.segment.start; - segmentTime <= props.segment.end + 0.1; - segmentTime += 0.1 - ) { - const index = Math.floor(segmentTime * 10); - const xTime = index / 10; - - const currentDb = - typeof waveform[index] === "number" ? waveform[index] : -60; - const amplitude = norm(currentDb) * maxAmplitude; - - const x = (xTime - props.segment.start) / secsPerPixel(); - const y = h - amplitude; - - const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); - const prevDb = - typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; - const prevAmplitude = norm(prevDb) * maxAmplitude; - const prevY = h - prevAmplitude; - - const cpX1 = prevX + step / 2; - const cpX2 = x - step / 2; - - ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); - } - - ctx.lineTo( - (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), - h, - ); - - ctx.closePath(); - ctx.fill(); - }; - - function renderWaveforms() { - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const w = width(); - if (w <= 0) return; - - const h = canvas.height; - canvas.width = w; - ctx.clearRect(0, 0, w, h); - - if (props.micWaveform) - render( - ctx, - h, - props.micWaveform, - "rgba(255,255,255,0.4)", - project.audio.micVolumeDb, - ); - - if (props.systemWaveform) - render( - ctx, - h, - props.systemWaveform, - "rgba(255,150,0,0.5)", - project.audio.systemVolumeDb, - ); - } - - createEffect(() => { - renderWaveforms(); - }); - - return ( - { - canvas = el; - renderWaveforms(); - }} - class="absolute inset-0 w-full h-full pointer-events-none" - height={52} - /> - ); + const { project } = useEditorContext(); + + let canvas: HTMLCanvasElement | undefined; + const { width } = useSegmentContext(); + const { secsPerPixel } = useTimelineContext(); + + const render = ( + ctx: CanvasRenderingContext2D, + h: number, + waveform: number[], + color: string, + gain = 0 + ) => { + const maxAmplitude = h; + + // yellow please + ctx.fillStyle = color; + ctx.beginPath(); + + const step = 0.05 / secsPerPixel(); + + ctx.moveTo(0, h); + + const norm = (w: number) => { + const ww = Number.isFinite(w) ? w : -60; + return 1.0 - Math.max(ww + gain, -60) / -60; + }; + + for ( + let segmentTime = props.segment.start; + segmentTime <= props.segment.end + 0.1; + segmentTime += 0.1 + ) { + const index = Math.floor(segmentTime * 10); + const xTime = index / 10; + + const currentDb = + typeof waveform[index] === "number" ? waveform[index] : -60; + const amplitude = norm(currentDb) * maxAmplitude; + + const x = (xTime - props.segment.start) / secsPerPixel(); + const y = h - amplitude; + + const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); + const prevDb = + typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; + const prevAmplitude = norm(prevDb) * maxAmplitude; + const prevY = h - prevAmplitude; + + const cpX1 = prevX + step / 2; + const cpX2 = x - step / 2; + + ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); + } + + ctx.lineTo( + (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), + h + ); + + ctx.closePath(); + ctx.fill(); + }; + + function renderWaveforms() { + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = width(); + if (w <= 0) return; + + const h = canvas.height; + canvas.width = w; + ctx.clearRect(0, 0, w, h); + + if (props.micWaveform) + render( + ctx, + h, + props.micWaveform, + "rgba(255,255,255,0.4)", + project.audio.micVolumeDb + ); + + if (props.systemWaveform) + render( + ctx, + h, + props.systemWaveform, + "rgba(255,150,0,0.5)", + project.audio.systemVolumeDb + ); + } + + createEffect(() => { + renderWaveforms(); + }); + + return ( + { + canvas = el; + renderWaveforms(); + }} + class="absolute inset-0 w-full h-full pointer-events-none" + height={52} + /> + ); } export function ClipTrack( - props: Pick, "ref"> & { - handleUpdatePlayhead: (e: MouseEvent) => void; - }, + props: Pick, "ref"> & { + handleUpdatePlayhead: (e: MouseEvent) => void; + } ) { - const { - project, - setProject, - projectActions, - editorInstance, - projectHistory, - editorState, - setEditorState, - totalDuration, - micWaveforms, - systemAudioWaveforms, - } = useEditorContext(); - - const { secsPerPixel, duration } = useTimelineContext(); - - const segments = (): Array => - project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; - - function onHandleReleased() { - const { transform } = editorState.timeline; - - if (transform.position + transform.zoom > totalDuration() + 4) { - transform.updateZoom(totalDuration(), editorState.previewTime!); - } - } - - const hasMultipleRecordingSegments = () => - editorInstance.recordings.segments.length > 1; - - const split = () => editorState.timeline.interactMode === "split"; - - return ( - setEditorState("timeline", "hoveredTrack", "clip")} - onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} - > - - {(segment, i) => { - const [startHandleDrag, setStartHandleDrag] = createSignal(null); - - const prefixOffsets = createMemo(() => { - const segs = segments(); - const out: number[] = new Array(segs.length); - let sum = 0; - for (let k = 0; k < segs.length; k++) { - out[k] = sum; - sum += (segs[k].end - segs[k].start) / segs[k].timescale; - } - return out; - }); - const prevDuration = createMemo(() => prefixOffsets()[i()] ?? 0); - - const relativeSegment = createMemo(() => { - const ds = startHandleDrag(); - const offset = ds ? ds.offset / segment.timescale : 0; - - return { - start: Math.max(prevDuration() + offset, 0), - end: - prevDuration() + - offset + - (segment.end - segment.start) / segment.timescale, - timescale: segment.timescale, - recordingSegment: segment.recordingSegment, - }; - }); - - const segmentX = useSegmentTranslateX(relativeSegment); - const segmentWidth = useSegmentWidth(relativeSegment); - - const segmentRecording = (s = i()) => - editorInstance.recordings.segments[ - segments()[s].recordingSegment ?? 0 - ]; - - const marker = useSectionMarker(() => ({ - segments: segments(), - i: i(), - position: "left", - })); - - const endMarker = useSectionMarker(() => ({ - segments: segments(), - i: i(), - position: "right", - })); - - const isSelected = createMemo(() => { - const selection = editorState.timeline.selection; - if (!selection || selection.type !== "clip") return false; - - const segmentIndex = project.timeline?.segments?.findIndex( - (s) => s.start === segment.start && s.end === segment.end, - ); - - if (segmentIndex === undefined || segmentIndex === -1) return false; - - return selection.indices.includes(segmentIndex); - }); - - const micWaveform = () => { - if (project.audio.micVolumeDb && project.audio.micVolumeDb < -30) - return; - - const idx = segment.recordingSegment ?? i(); - return micWaveforms()?.[idx] ?? []; - }; - - const systemAudioWaveform = () => { - if ( - project.audio.systemVolumeDb && - project.audio.systemVolumeDb <= -30 - ) - return; - - const idx = segment.recordingSegment ?? i(); - return systemAudioWaveforms()?.[idx] ?? []; - }; - - return ( - <> - - {(marker) => ( -
-
- - { - const m = marker(); - if (m.type === "single") return m.value; - })()} - > - {(markerValue) => { - const value = createMemo(() => { - const m = markerValue(); - return m.type === "time" ? m.time : 0; - }); - - return ( -
- { - setProject( - "timeline", - "segments", - produce((s) => { - if (markerValue().type === "reset") { - s[i() - 1].end = s[i()].end; - s.splice(i(), 1); - } else { - s[i() - 1].end = s[i()].start; - } - }), - ); - }} - /> -
- ); - }} -
- { - const m = marker(); - if ( - m.type === "dual" && - m.right && - m.right.type === "time" - ) - return m.right; - })()} - > - {(markerValue) => { - const value = createMemo(() => { - const m = markerValue(); - return m.type === "time" ? m.time : 0; - }); - - return ( -
- { - setProject( - "timeline", - "segments", - i(), - "start", - 0, - ); - }} - /> -
- ); - }} -
-
-
- )} - - { - e.stopPropagation(); - - if (editorState.timeline.interactMode === "split") { - const rect = e.currentTarget.getBoundingClientRect(); - const fraction = (e.clientX - rect.left) / rect.width; - - const splitTime = fraction * (segment.end - segment.start); - - projectActions.splitClipSegment(prevDuration() + splitTime); - } else { - createRoot((dispose) => { - createEventListener( - e.currentTarget, - "mouseup", - (upEvent) => { - dispose(); - - const currentIndex = i(); - const selection = editorState.timeline.selection; - const isMac = - navigator.platform.toUpperCase().indexOf("MAC") >= - 0; - const isMultiSelect = isMac - ? upEvent.metaKey - : upEvent.ctrlKey; - const isRangeSelect = upEvent.shiftKey; - - if ( - isRangeSelect && - selection && - selection.type === "clip" - ) { - // Range selection: select from last selected to current - const existingIndices = selection.indices; - const lastIndex = - existingIndices[existingIndices.length - 1]; - const start = Math.min(lastIndex, currentIndex); - const end = Math.max(lastIndex, currentIndex); - const rangeIndices = Array.from( - { length: end - start + 1 }, - (_, idx) => start + idx, - ); - - setEditorState("timeline", "selection", { - type: "clip" as const, - indices: rangeIndices, - }); - } else if ( - isMultiSelect && - selection && - selection.type === "clip" - ) { - // Multi-select: toggle current index - const existingIndices = selection.indices; - - if (existingIndices.includes(currentIndex)) { - // Remove from selection - const newIndices = existingIndices.filter( - (idx) => idx !== currentIndex, - ); - if (newIndices.length > 0) { - setEditorState("timeline", "selection", { - type: "clip" as const, - indices: newIndices, - }); - } else { - setEditorState("timeline", "selection", null); - } - } else { - // Add to selection - setEditorState("timeline", "selection", { - type: "clip" as const, - indices: [...existingIndices, currentIndex], - }); - } - } else { - // Normal single selection - setEditorState("timeline", "selection", { - type: "clip" as const, - indices: [currentIndex], - }); - } - - props.handleUpdatePlayhead(upEvent); - }, - ); - }); - } - }} - > - - - - - { - if (split()) return; - - const initialStart = segment.start; - setStartHandleDrag({ - offset: 0, - initialStart, - }); - - const maxSegmentDuration = - editorInstance.recordings.segments[ - segment.recordingSegment ?? 0 - ].display.duration; - - const availableTimelineDuration = - editorInstance.recordingDuration - - segments().reduce( - (acc, segment, segmentI) => - segmentI === i() - ? acc - : acc + - (segment.end - segment.start) / segment.timescale, - 0, - ); - - const maxDuration = Math.min( - maxSegmentDuration, - availableTimelineDuration, - ); - - const prevSegment = segments()[i() - 1]; - const prevSegmentIsSameClip = - prevSegment?.recordingSegment !== undefined - ? prevSegment.recordingSegment === - segment.recordingSegment - : false; - - function update(event: MouseEvent) { - const newStart = - initialStart + - (event.clientX - downEvent.clientX) * - secsPerPixel() * - segment.timescale; - - const clampedStart = Math.min( - Math.max( - newStart, - prevSegmentIsSameClip ? prevSegment.end : 0, - segment.end - maxDuration, - ), - segment.end - 1, - ); - - setStartHandleDrag({ - offset: clampedStart - initialStart, - initialStart, - }); - - setProject( - "timeline", - "segments", - i(), - "start", - clampedStart, - ); - } - - const resumeHistory = projectHistory.pause(); - createRoot((dispose) => { - onCleanup(() => { - resumeHistory(); - console.log("NUL"); - setStartHandleDrag(null); - onHandleReleased(); - }); - - createEventListenerMap(window, { - mousemove: update, - mouseup: (e) => { - update(e); - dispose(); - }, - blur: () => dispose(), - mouseleave: () => dispose(), - }); - }); - }} - /> - - {(() => { - const ctx = useSegmentContext(); - - return ( - 100}> -
- - {hasMultipleRecordingSegments() - ? `Clip ${segment.recordingSegment}` - : "Clip"} - -
- {" "} - {formatTime(segment.end - segment.start)} -
-
-
- ); - })()} -
- { - const end = segment.end; - - if (split()) return; - const maxSegmentDuration = - editorInstance.recordings.segments[ - segment.recordingSegment ?? 0 - ].display.duration; - - const availableTimelineDuration = - editorInstance.recordingDuration - - segments().reduce( - (acc, segment, segmentI) => - segmentI === i() - ? acc - : acc + - (segment.end - segment.start) / segment.timescale, - 0, - ); - - const nextSegment = segments()[i() + 1]; - const nextSegmentIsSameClip = - nextSegment?.recordingSegment !== undefined - ? nextSegment.recordingSegment === - segment.recordingSegment - : false; - - function update(event: MouseEvent) { - const deltaRecorded = - (event.clientX - downEvent.clientX) * - secsPerPixel() * - segment.timescale; - const newEnd = end + deltaRecorded; - - setProject( - "timeline", - "segments", - i(), - "end", - Math.max( - Math.min( - newEnd, - // availableTimelineDuration is in timeline seconds; convert to recorded seconds - end + availableTimelineDuration * segment.timescale, - nextSegmentIsSameClip - ? nextSegment.start - : maxSegmentDuration, - ), - segment.start + 1, - ), - ); - } - - const resumeHistory = projectHistory.pause(); - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: update, - mouseup: (e) => { - dispose(); - resumeHistory(); - update(e); - onHandleReleased(); - }, - blur: () => { - dispose(); - resumeHistory(); - onHandleReleased(); - }, - mouseleave: () => { - dispose(); - resumeHistory(); - onHandleReleased(); - }, - }); - }); - }} - /> -
- { - const m = endMarker(); - if (m?.type === "dual" && m.left && m.left.type === "time") - return m.left; - })()} - > - {(markerValue) => { - const value = createMemo(() => { - const m = markerValue(); - return m.type === "time" ? m.time : 0; - }); - - return ( -
-
-
- { - setProject( - "timeline", - "segments", - i(), - "end", - segmentRecording().display.duration, - ); - }} - /> -
-
- ); - }} - - - ); - }} - - - ); + const { + project, + setProject, + projectActions, + editorInstance, + projectHistory, + editorState, + setEditorState, + totalDuration, + micWaveforms, + systemAudioWaveforms, + } = useEditorContext(); + + const { secsPerPixel, duration } = useTimelineContext(); + + const segments = (): Array => + project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; + + function onHandleReleased() { + const { transform } = editorState.timeline; + + if (transform.position + transform.zoom > totalDuration() + 4) { + transform.updateZoom(totalDuration(), editorState.previewTime!); + } + } + + const hasMultipleRecordingSegments = () => + editorInstance.recordings.segments.length > 1; + + const split = () => editorState.timeline.interactMode === "split"; + + return ( + setEditorState("timeline", "hoveredTrack", "clip")} + onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} + > + + {(segment, i) => { + const [startHandleDrag, setStartHandleDrag] = createSignal(null); + + const prefixOffsets = createMemo(() => { + const segs = segments(); + const out: number[] = new Array(segs.length); + let sum = 0; + for (let k = 0; k < segs.length; k++) { + out[k] = sum; + sum += (segs[k].end - segs[k].start) / segs[k].timescale; + } + return out; + }); + const prevDuration = createMemo(() => prefixOffsets()[i()] ?? 0); + + const relativeSegment = createMemo(() => { + const ds = startHandleDrag(); + const offset = ds ? ds.offset / segment.timescale : 0; + + return { + start: Math.max(prevDuration() + offset, 0), + end: + prevDuration() + + offset + + (segment.end - segment.start) / segment.timescale, + timescale: segment.timescale, + recordingSegment: segment.recordingSegment, + }; + }); + + const segmentX = useSegmentTranslateX(relativeSegment); + const segmentWidth = useSegmentWidth(relativeSegment); + + const segmentRecording = (s = i()) => + editorInstance.recordings.segments[ + segments()[s].recordingSegment ?? 0 + ]; + + const marker = useSectionMarker(() => ({ + segments: segments(), + i: i(), + position: "left", + })); + + const endMarker = useSectionMarker(() => ({ + segments: segments(), + i: i(), + position: "right", + })); + + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "clip") return false; + + const segmentIndex = project.timeline?.segments?.findIndex( + (s) => s.start === segment.start && s.end === segment.end + ); + + if (segmentIndex === undefined || segmentIndex === -1) return false; + + return selection.indices.includes(segmentIndex); + }); + + const micWaveform = () => { + if (project.audio.micVolumeDb && project.audio.micVolumeDb < -30) + return; + + const idx = segment.recordingSegment ?? i(); + return micWaveforms()?.[idx] ?? []; + }; + + const systemAudioWaveform = () => { + if ( + project.audio.systemVolumeDb && + project.audio.systemVolumeDb <= -30 + ) + return; + + const idx = segment.recordingSegment ?? i(); + return systemAudioWaveforms()?.[idx] ?? []; + }; + + return ( + <> + + {(marker) => ( +
+
+ + { + const m = marker(); + if (m.type === "single") return m.value; + })()} + > + {(markerValue) => { + const value = createMemo(() => { + const m = markerValue(); + return m.type === "time" ? m.time : 0; + }); + + return ( +
+ { + setProject( + "timeline", + "segments", + produce((s) => { + if (markerValue().type === "reset") { + s[i() - 1].end = s[i()].end; + s.splice(i(), 1); + } else { + s[i() - 1].end = s[i()].start; + } + }) + ); + }} + /> +
+ ); + }} +
+ { + const m = marker(); + if ( + m.type === "dual" && + m.right && + m.right.type === "time" + ) + return m.right; + })()} + > + {(markerValue) => { + const value = createMemo(() => { + const m = markerValue(); + return m.type === "time" ? m.time : 0; + }); + + return ( +
+ { + setProject( + "timeline", + "segments", + i(), + "start", + 0 + ); + }} + /> +
+ ); + }} +
+
+
+ )} + + { + e.stopPropagation(); + + if (editorState.timeline.interactMode === "split") { + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = (e.clientX - rect.left) / rect.width; + + const splitTime = fraction * (segment.end - segment.start); + + projectActions.splitClipSegment(prevDuration() + splitTime); + } else { + createRoot((dispose) => { + createEventListener( + e.currentTarget, + "mouseup", + (upEvent) => { + dispose(); + + const currentIndex = i(); + const selection = editorState.timeline.selection; + const isMac = + navigator.platform.toUpperCase().indexOf("MAC") >= + 0; + const isMultiSelect = isMac + ? upEvent.metaKey + : upEvent.ctrlKey; + const isRangeSelect = upEvent.shiftKey; + + if ( + isRangeSelect && + selection && + selection.type === "clip" + ) { + // Range selection: select from last selected to current + const existingIndices = selection.indices; + const lastIndex = + existingIndices[existingIndices.length - 1]; + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + const rangeIndices = Array.from( + { length: end - start + 1 }, + (_, idx) => start + idx + ); + + setEditorState("timeline", "selection", { + type: "clip" as const, + indices: rangeIndices, + }); + } else if ( + isMultiSelect && + selection && + selection.type === "clip" + ) { + // Multi-select: toggle current index + const existingIndices = selection.indices; + + if (existingIndices.includes(currentIndex)) { + // Remove from selection + const newIndices = existingIndices.filter( + (idx) => idx !== currentIndex + ); + if (newIndices.length > 0) { + setEditorState("timeline", "selection", { + type: "clip" as const, + indices: newIndices, + }); + } else { + setEditorState("timeline", "selection", null); + } + } else { + // Add to selection + setEditorState("timeline", "selection", { + type: "clip" as const, + indices: [...existingIndices, currentIndex], + }); + } + } else { + // Normal single selection + setEditorState("timeline", "selection", { + type: "clip" as const, + indices: [currentIndex], + }); + } + + props.handleUpdatePlayhead(upEvent); + } + ); + }); + } + }} + > + + + + + { + if (split()) return; + + const initialStart = segment.start; + setStartHandleDrag({ + offset: 0, + initialStart, + }); + + const maxSegmentDuration = + editorInstance.recordings.segments[ + segment.recordingSegment ?? 0 + ].display.duration; + + const availableTimelineDuration = + editorInstance.recordingDuration - + segments().reduce( + (acc, segment, segmentI) => + segmentI === i() + ? acc + : acc + + (segment.end - segment.start) / segment.timescale, + 0 + ); + + const maxDuration = Math.min( + maxSegmentDuration, + availableTimelineDuration + ); + + const prevSegment = segments()[i() - 1]; + const prevSegmentIsSameClip = + prevSegment?.recordingSegment !== undefined + ? prevSegment.recordingSegment === + segment.recordingSegment + : false; + + function update(event: MouseEvent) { + const newStart = + initialStart + + (event.clientX - downEvent.clientX) * + secsPerPixel() * + segment.timescale; + + const clampedStart = Math.min( + Math.max( + newStart, + prevSegmentIsSameClip ? prevSegment.end : 0, + segment.end - maxDuration + ), + segment.end - 1 + ); + + setStartHandleDrag({ + offset: clampedStart - initialStart, + initialStart, + }); + + setProject( + "timeline", + "segments", + i(), + "start", + clampedStart + ); + } + + const resumeHistory = projectHistory.pause(); + createRoot((dispose) => { + onCleanup(() => { + resumeHistory(); + console.log("NUL"); + setStartHandleDrag(null); + onHandleReleased(); + }); + + createEventListenerMap(window, { + mousemove: update, + mouseup: (e) => { + update(e); + dispose(); + }, + blur: () => dispose(), + mouseleave: () => dispose(), + }); + }); + }} + /> + + {(() => { + const ctx = useSegmentContext(); + + return ( + 100}> +
+ + {hasMultipleRecordingSegments() + ? `Clip ${segment.recordingSegment}` + : "Clip"} + +
+ {" "} + {formatTime(segment.end - segment.start)} +
+
+
+ ); + })()} +
+ { + const end = segment.end; + + if (split()) return; + const maxSegmentDuration = + editorInstance.recordings.segments[ + segment.recordingSegment ?? 0 + ].display.duration; + + const availableTimelineDuration = + editorInstance.recordingDuration - + segments().reduce( + (acc, segment, segmentI) => + segmentI === i() + ? acc + : acc + + (segment.end - segment.start) / segment.timescale, + 0 + ); + + const nextSegment = segments()[i() + 1]; + const nextSegmentIsSameClip = + nextSegment?.recordingSegment !== undefined + ? nextSegment.recordingSegment === + segment.recordingSegment + : false; + + function update(event: MouseEvent) { + const deltaRecorded = + (event.clientX - downEvent.clientX) * + secsPerPixel() * + segment.timescale; + const newEnd = end + deltaRecorded; + + setProject( + "timeline", + "segments", + i(), + "end", + Math.max( + Math.min( + newEnd, + // availableTimelineDuration is in timeline seconds; convert to recorded seconds + end + availableTimelineDuration * segment.timescale, + nextSegmentIsSameClip + ? nextSegment.start + : maxSegmentDuration + ), + segment.start + 1 + ) + ); + } + + const resumeHistory = projectHistory.pause(); + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: update, + mouseup: (e) => { + dispose(); + resumeHistory(); + update(e); + onHandleReleased(); + }, + blur: () => { + dispose(); + resumeHistory(); + onHandleReleased(); + }, + mouseleave: () => { + dispose(); + resumeHistory(); + onHandleReleased(); + }, + }); + }); + }} + /> +
+ { + const m = endMarker(); + if (m?.type === "dual" && m.left && m.left.type === "time") + return m.left; + })()} + > + {(markerValue) => { + const value = createMemo(() => { + const m = markerValue(); + return m.type === "time" ? m.time : 0; + }); + + return ( +
+
+
+ { + setProject( + "timeline", + "segments", + i(), + "end", + segmentRecording().display.duration + ); + }} + /> +
+
+ ); + }} + + + ); + }} + + + ); } function Markings(props: { segment: TimelineSegment; prevDuration: number }) { - const { editorState } = useEditorContext(); - const { secsPerPixel, markingResolution } = useTimelineContext(); - - const markings = () => { - const resolution = markingResolution(); - - const { transform } = editorState.timeline; - const visibleMin = - transform.position - props.prevDuration + props.segment.start; - const visibleMax = visibleMin + transform.zoom; - - const start = Math.floor(visibleMin / resolution); - - return Array.from( - { length: Math.ceil(visibleMax / resolution) - start }, - (_, i) => (start + i) * resolution, - ); - }; - - return ( - - {(marking) => ( -
- )} - - ); + const { editorState } = useEditorContext(); + const { secsPerPixel, markingResolution } = useTimelineContext(); + + const markings = () => { + const resolution = markingResolution(); + + const { transform } = editorState.timeline; + const visibleMin = + transform.position - props.prevDuration + props.segment.start; + const visibleMax = visibleMin + transform.zoom; + + const start = Math.floor(visibleMin / resolution); + + return Array.from( + { length: Math.ceil(visibleMax / resolution) - start }, + (_, i) => (start + i) * resolution + ); + }; + + return ( + + {(marking) => ( +
+ )} + + ); } function CutOffsetButton(props: { - value: number; - class?: string; - onClick?(): void; + value: number; + class?: string; + onClick?(): void; }) { - return ( - - ); + return ( + + ); } function useSectionMarker( - props: () => { - segments: TimelineSegment[]; - i: number; - position: "left" | "right"; - }, + props: () => { + segments: TimelineSegment[]; + i: number; + position: "left" | "right"; + } ) { - const { editorInstance } = useEditorContext(); + const { editorInstance } = useEditorContext(); - return () => getSectionMarker(props(), editorInstance.recordings.segments); + return () => getSectionMarker(props(), editorInstance.recordings.segments); } diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 6d27c4b0da..2974ec8e72 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -17,6 +17,7 @@ import { SelectedCapsBar, UploadCapButton, UploadPlaceholderCard, + WebRecorderDialog, } from "./components"; import { CapCard } from "./components/CapCard/CapCard"; import { CapPagination } from "./components/CapPagination"; @@ -240,6 +241,7 @@ export const Caps = ({ New Folder +
{folders.length > 0 && ( <> diff --git a/apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx b/apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx index 4d17339ad9..16b8ff2179 100644 --- a/apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRive } from "@rive-app/react-canvas"; import { useTheme } from "../../Contexts"; import { UploadCapButton } from "./UploadCapButton"; +import { WebRecorderDialog } from "./WebRecorderDialog/WebRecorderDialog"; interface EmptyCapStateProps { userName?: string; @@ -30,7 +31,7 @@ export const EmptyCapState: React.FC = ({ userName }) => { Craft your narrative with Cap - get projects done quicker.

-
+

or

+ +

or

diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index 885642e06c..7be95c89de 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -18,6 +18,7 @@ import { } from "@/app/(org)/dashboard/caps/UploadingContext"; import { UpgradeModal } from "@/components/UpgradeModal"; import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest"; +import { sendProgressUpdate } from "./sendProgressUpdate"; export const UploadCapButton = ({ size = "md", @@ -517,29 +518,3 @@ async function legacyUploadCap( setUploadStatus(undefined); return false; } - -const sendProgressUpdate = async ( - videoId: string, - uploaded: number, - total: number, -) => { - try { - const response = await fetch("/api/desktop/video/progress", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - videoId, - uploaded, - total, - updatedAt: new Date().toISOString(), - }), - }); - - if (!response.ok) - console.error("Failed to send progress update:", response.status); - } catch (err) { - console.error("Error sending progress update:", err); - } -}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx new file mode 100644 index 0000000000..a695943901 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { X, Maximize2, Circle, Square, RectangleHorizontal, FlipHorizontal } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import clsx from "clsx"; + +type CameraPreviewSize = "sm" | "lg"; +type CameraPreviewShape = "round" | "square" | "full"; + +interface CameraPreviewWindowProps { + cameraId: string; + onClose: () => void; +} + +export const CameraPreviewWindow = ({ + cameraId, + onClose, +}: CameraPreviewWindowProps) => { + const [size, setSize] = useState("sm"); + const [shape, setShape] = useState("round"); + const [mirrored, setMirrored] = useState(false); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const videoRef = useRef(null); + const streamRef = useRef(null); + const containerRef = useRef(null); + const [videoDimensions, setVideoDimensions] = useState<{ width: number; height: number } | null>(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + return () => { + setMounted(false); + }; + }, []); + + useEffect(() => { + const startCamera = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + deviceId: { exact: cameraId }, + }, + }); + + streamRef.current = stream; + + if (videoRef.current) { + videoRef.current.srcObject = stream; + } + + const calculateInitialPosition = () => { + const padding = 20; + const base = size === "sm" ? 230 : 400; + const barHeight = 52; + const windowWidth = base; + const windowHeight = base + barHeight; + const x = padding; + const y = window.innerHeight - windowHeight - padding; + setPosition({ x, y }); + }; + + setTimeout(calculateInitialPosition, 100); + } catch (err) { + console.error("Failed to start camera", err); + } + }; + + startCamera(); + + return () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + }; + }, [cameraId]); + + useEffect(() => { + if (videoRef.current && streamRef.current && !videoRef.current.srcObject) { + videoRef.current.srcObject = streamRef.current; + } + }, [position]); + + useEffect(() => { + if (position) { + const padding = 20; + const base = size === "sm" ? 230 : 400; + const barHeight = 52; + const windowWidth = base; + const windowHeight = base + barHeight; + + setPosition((prev) => { + if (!prev) return { x: padding, y: window.innerHeight - windowHeight - padding }; + const maxX = window.innerWidth - windowWidth; + const maxY = window.innerHeight - windowHeight; + return { + x: Math.max(0, Math.min(prev.x, maxX)), + y: Math.max(0, Math.min(prev.y, maxY)), + }; + }); + } + }, [size]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('[data-controls]')) { + return; + } + e.stopPropagation(); + e.preventDefault(); + setIsDragging(true); + setDragStart({ + x: e.clientX - (position?.x || 0), + y: e.clientY - (position?.y || 0), + }); + }, [position]); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDragging) return; + + const newX = e.clientX - dragStart.x; + const newY = e.clientY - dragStart.y; + + const base = size === "sm" ? 230 : 400; + const barHeight = 52; + const aspectRatio = videoDimensions + ? videoDimensions.width / videoDimensions.height + : 1; + const windowWidth = + shape === "full" ? (aspectRatio >= 1 ? base * aspectRatio : base) : base; + const windowHeight = + shape === "full" ? (aspectRatio >= 1 ? base : base / aspectRatio) : base; + const totalWidth = windowWidth; + const totalHeight = windowHeight + barHeight; + const maxX = window.innerWidth - totalWidth; + const maxY = window.innerHeight - totalHeight; + + setPosition({ + x: Math.max(0, Math.min(newX, maxX)), + y: Math.max(0, Math.min(newY, maxY)), + }); + }, [isDragging, dragStart, size, shape, videoDimensions]); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + if (!mounted || !position) { + return null; + } + + const base = size === "sm" ? 230 : 400; + const barHeight = 52; + const aspectRatio = videoDimensions + ? videoDimensions.width / videoDimensions.height + : 1; + + const windowWidth = + shape === "full" ? (aspectRatio >= 1 ? base * aspectRatio : base) : base; + const windowHeight = + shape === "full" ? (aspectRatio >= 1 ? base : base / aspectRatio) : base; + const totalHeight = windowHeight + barHeight; + + const borderRadius = + shape === "round" ? "9999px" : size === "sm" ? "3rem" : "4rem"; + + + return createPortal( +
{ + e.stopPropagation(); + e.preventDefault(); + handleMouseDown(e); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > +
+
+
+
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + + + +
+
+
+ +
+
+
+
+ , document.body); +}; + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx new file mode 100644 index 0000000000..4cd639df3f --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { + SelectRoot, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@cap/ui"; +import clsx from "clsx"; +import { CameraIcon, CameraOffIcon } from "lucide-react"; +import { toast } from "sonner"; +import { NO_CAMERA, NO_CAMERA_VALUE } from "./web-recorder-constants"; +import { useMediaPermission } from "./useMediaPermission"; + +import type { KeyboardEvent, MouseEvent } from "react"; + +interface CameraSelectorProps { + selectedCameraId: string | null; + availableCameras: MediaDeviceInfo[]; + dialogOpen: boolean; + disabled?: boolean; + onCameraChange: (cameraId: string | null) => void; + onRefreshDevices: () => Promise | void; +} + +export const CameraSelector = ({ + selectedCameraId, + availableCameras, + dialogOpen, + disabled = false, + onCameraChange, + onRefreshDevices, +}: CameraSelectorProps) => { + const cameraEnabled = selectedCameraId !== null; + const { state: permissionState, requestPermission } = useMediaPermission( + "camera", + dialogOpen + ); + + const permissionSupported = permissionState !== "unsupported"; + const shouldRequestPermission = + permissionSupported && permissionState !== "granted"; + + const statusPillClassName = clsx( + "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none", + shouldRequestPermission + ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" + : cameraEnabled + ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)]" + : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" + ); + + const handleStatusPillClick = async ( + event: MouseEvent + ) => { + if (!shouldRequestPermission) return; + event.preventDefault(); + event.stopPropagation(); + + try { + const granted = await requestPermission(); + if (granted) { + await Promise.resolve(onRefreshDevices()); + } + } catch (error) { + console.error("Camera permission request failed", error); + toast.error("Unable to access your camera. Check browser permissions."); + } + }; + + return ( +
+ { + onCameraChange(value === NO_CAMERA_VALUE ? null : value); + }} + disabled={disabled} + > + svg]:hidden", + disabled || shouldRequestPermission ? "cursor-default" : undefined + )} + onPointerDown={(event) => { + if (shouldRequestPermission) { + event.preventDefault(); + event.stopPropagation(); + } + }} + onKeyDown={(event: KeyboardEvent) => { + if (shouldRequestPermission) { + const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; + if (keys.includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } + } + }} + aria-disabled={disabled || shouldRequestPermission} + > + + + + + + + + {NO_CAMERA} + + + {availableCameras.map((camera, index) => ( + + + + {camera.label?.trim() || `Camera ${index + 1}`} + + + ))} + + +
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx new file mode 100644 index 0000000000..a6a9083c80 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { + SelectRoot, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@cap/ui"; +import clsx from "clsx"; +import { MicIcon, MicOffIcon } from "lucide-react"; +import { toast } from "sonner"; +import { NO_MICROPHONE, NO_MICROPHONE_VALUE } from "./web-recorder-constants"; +import { useMediaPermission } from "./useMediaPermission"; + +import type { KeyboardEvent, MouseEvent } from "react"; + +interface MicrophoneSelectorProps { + selectedMicId: string | null; + availableMics: MediaDeviceInfo[]; + dialogOpen: boolean; + disabled?: boolean; + onMicChange: (micId: string | null) => void; + onRefreshDevices: () => Promise | void; +} + +export const MicrophoneSelector = ({ + selectedMicId, + availableMics, + dialogOpen, + disabled = false, + onMicChange, + onRefreshDevices, +}: MicrophoneSelectorProps) => { + const micEnabled = selectedMicId !== null; + const { state: permissionState, requestPermission } = useMediaPermission( + "microphone", + dialogOpen + ); + + const permissionSupported = permissionState !== "unsupported"; + const shouldRequestPermission = + permissionSupported && permissionState !== "granted"; + + const statusPillClassName = clsx( + "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none", + shouldRequestPermission + ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" + : micEnabled + ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)]" + : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" + ); + + const handleStatusPillClick = async ( + event: MouseEvent + ) => { + if (!shouldRequestPermission) return; + event.preventDefault(); + event.stopPropagation(); + + try { + const granted = await requestPermission(); + if (granted) { + await Promise.resolve(onRefreshDevices()); + } + } catch (error) { + console.error("Microphone permission request failed", error); + toast.error( + "Unable to access your microphone. Check browser permissions." + ); + } + }; + + return ( +
+ { + onMicChange(value === NO_MICROPHONE_VALUE ? null : value); + }} + disabled={disabled} + > + svg]:hidden", + disabled || shouldRequestPermission ? "cursor-default" : undefined + )} + onPointerDown={(event) => { + if (shouldRequestPermission) { + event.preventDefault(); + event.stopPropagation(); + } + }} + onKeyDown={(event: KeyboardEvent) => { + if (shouldRequestPermission) { + const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; + if (keys.includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } + } + }} + aria-disabled={disabled || shouldRequestPermission} + > + + + + + + + + {NO_MICROPHONE} + + + {availableMics.map((mic, index) => ( + + + + {mic.label?.trim() || `Microphone ${index + 1}`} + + + ))} + + +
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx new file mode 100644 index 0000000000..7837dbf634 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Button } from "@cap/ui"; +import { PlayIcon } from "lucide-react"; + +interface RecordingButtonProps { + isRecording: boolean; + disabled?: boolean; + onStart: () => void; + onStop: () => void; +} + +export const RecordingButton = ({ + isRecording, + disabled = false, + onStart, + onStop, +}: RecordingButtonProps) => { + return ( +
+ +
+ ); +}; + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx new file mode 100644 index 0000000000..0ea0f31561 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + SelectRoot, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@cap/ui"; +import { + CameraIcon, + Globe, + MonitorIcon, + RectangleHorizontal, + type LucideIcon, +} from "lucide-react"; + +export type RecordingMode = "fullscreen" | "window" | "tab" | "camera"; + +interface RecordingModeSelectorProps { + mode: RecordingMode; + disabled?: boolean; + onModeChange: (mode: RecordingMode) => void; +} + +export const RecordingModeSelector = ({ + mode, + disabled = false, + onModeChange, +}: RecordingModeSelectorProps) => { + const recordingModeOptions: Record< + RecordingMode, + { + label: string; + icon: LucideIcon; + } + > = { + fullscreen: { + label: "Full Screen", + icon: MonitorIcon, + }, + window: { + label: "Window", + icon: RectangleHorizontal, + }, + tab: { + label: "Current tab", + icon: Globe, + }, + camera: { + label: "Camera only", + icon: CameraIcon, + }, + }; + + return ( +
+ { + onModeChange(value as RecordingMode); + }} + disabled={disabled} + > + + + + + {Object.entries(recordingModeOptions).map(([value, option]) => { + const OptionIcon = option.icon; + + return ( + + + + {option.label} + + + ); + })} + + +
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx new file mode 100644 index 0000000000..44520e43a2 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { + Button, + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@cap/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { MonitorIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useDashboardContext } from "../../../Contexts"; +import { CameraSelector } from "./CameraSelector"; +import { CameraPreviewWindow } from "./CameraPreviewWindow"; +import { MicrophoneSelector } from "./MicrophoneSelector"; +import { RecordingButton } from "./RecordingButton"; +import { + RecordingModeSelector, + type RecordingMode, +} from "./RecordingModeSelector"; +import { WebRecorderDialogHeader } from "./WebRecorderDialogHeader"; +import { dialogVariants } from "./web-recorder-constants"; +import { useCameraDevices } from "./useCameraDevices"; +import { useMicrophoneDevices } from "./useMicrophoneDevices"; +import { useWebRecorder } from "./useWebRecorder"; + +export const WebRecorderDialog = () => { + const [open, setOpen] = useState(false); + const [selectedMicId, setSelectedMicId] = useState(null); + const [recordingMode, setRecordingMode] = + useState("fullscreen"); + const [selectedCameraId, setSelectedCameraId] = useState(null); + const dialogContentRef = useRef(null); + + const { activeOrganization } = useDashboardContext(); + const organisationId = activeOrganization?.organization.id; + const { devices: availableMics, refresh: refreshMics } = + useMicrophoneDevices(open); + const { devices: availableCameras, refresh: refreshCameras } = + useCameraDevices(open); + + const micEnabled = selectedMicId !== null; + + useEffect(() => { + if ( + recordingMode === "camera" && + !selectedCameraId && + availableCameras.length > 0 + ) { + setSelectedCameraId(availableCameras[0]?.deviceId ?? null); + } + }, [recordingMode, selectedCameraId, availableCameras]); + + const { + isRecording, + isBusy, + canStartRecording, + startRecording, + stopRecording, + resetState, + } = useWebRecorder({ + organisationId, + selectedMicId, + micEnabled, + recordingMode, + selectedCameraId, + }); + + const handleOpenChange = (next: boolean) => { + if (!next && isBusy) { + toast.info("Keep this dialog open while your upload finishes."); + return; + } + + if (!next) { + resetState(); + setSelectedCameraId(null); + setRecordingMode("fullscreen"); + } + setOpen(next); + }; + + const handleStopClick = () => { + stopRecording().catch((err: unknown) => { + console.error("Stop recording error", err); + }); + }; + + const handleClose = () => { + if (!isBusy) { + handleOpenChange(false); + } + }; + + const handleCameraChange = (cameraId: string | null) => { + setSelectedCameraId(cameraId); + }; + + return ( + <> + + + + + { + const originalEvent = event.detail.originalEvent as + | PointerEvent + | undefined; + const target = originalEvent?.target as Element | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = originalEvent?.composedPath() || []; + const dialogContent = dialogContentRef.current; + + const isInsideDialog = (el: Element) => { + if (!dialogContent) return false; + return dialogContent.contains(el); + }; + + const isWhitelisted = (el: Element) => { + if (isInsideDialog(el)) return true; + if (el.closest('[data-slot="select-content"]')) return true; + if (el.closest("[data-radix-select-content]")) return true; + if (el.closest("[data-radix-select-viewport]")) return true; + if (el.closest("[data-radix-select-item]")) return true; + if (el.closest("[data-camera-preview]")) return true; + return false; + }; + + if ( + (target && isWhitelisted(target)) || + path.some( + (t) => t instanceof Element && isWhitelisted(t as Element) + ) + ) { + event.preventDefault(); + } + }} + onFocusOutside={(event) => { + const target = event.target as Element | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = + (event.detail?.originalEvent as FocusEvent)?.composedPath?.() || + []; + const dialogContent = dialogContentRef.current; + + const isInsideDialog = (el: Element) => { + if (!dialogContent) return false; + return dialogContent.contains(el); + }; + + const isWhitelisted = (el: Element) => { + if (isInsideDialog(el)) return true; + if (el.closest('[data-slot="select-content"]')) return true; + if (el.closest("[data-radix-select-content]")) return true; + if (el.closest("[data-radix-select-viewport]")) return true; + if (el.closest("[data-radix-select-item]")) return true; + if (el.closest("[data-camera-preview]")) return true; + return false; + }; + + if ( + (target && isWhitelisted(target)) || + path.some( + (t) => t instanceof Element && isWhitelisted(t as Element) + ) + ) { + event.preventDefault(); + } + }} + onInteractOutside={(event) => { + const originalEvent = event.detail.originalEvent as + | Event + | undefined; + const target = originalEvent?.target as Element | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = originalEvent?.composedPath?.() || []; + const dialogContent = dialogContentRef.current; + + const isInsideDialog = (el: Element) => { + if (!dialogContent) return false; + return dialogContent.contains(el); + }; + + const isWhitelisted = (el: Element) => { + if (isInsideDialog(el)) return true; + if (el.closest('[data-slot="select-content"]')) return true; + if (el.closest("[data-radix-select-content]")) return true; + if (el.closest("[data-radix-select-viewport]")) return true; + if (el.closest("[data-radix-select-item]")) return true; + if (el.closest("[data-camera-preview]")) return true; + return false; + }; + + if ( + (target && isWhitelisted(target)) || + path.some( + (t) => t instanceof Element && isWhitelisted(t as Element) + ) + ) { + event.preventDefault(); + } + }} + > + Instant Mode Recorder + + {open && ( + + + + + + + + )} + + + + {selectedCameraId && ( + setSelectedCameraId(null)} + /> + )} + + ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx new file mode 100644 index 0000000000..73c70dc878 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx @@ -0,0 +1,98 @@ +"use client"; + +import clsx from "clsx"; +import { useDashboardContext } from "../../../Contexts"; + +interface WebRecorderDialogHeaderProps { + isBusy: boolean; + onClose: () => void; +} + +export const WebRecorderDialogHeader = ({ + isBusy, + onClose, +}: WebRecorderDialogHeaderProps) => { + const { user, setUpgradeModalOpen } = useDashboardContext(); + const planLabel = user.isPro ? "Pro" : "Free"; + const planClassName = clsx( + "ml-2 inline-flex items-center rounded-md px-1.5 py-0.5 text-[0.7rem] font-medium transition-colors", + user.isPro + ? "bg-blue-9 text-gray-1" + : "cursor-pointer bg-gray-3 text-gray-12 hover:bg-gray-4" + ); + + return ( + <> +
+
+
+
+ + Cap Logo + + + + + + + + { + if (!user.isPro) setUpgradeModalOpen(true); + }} + className={planClassName} + > + {planLabel} + +
+
+ + ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useCameraDevices.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useCameraDevices.ts new file mode 100644 index 0000000000..5e97819e20 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useCameraDevices.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export const useCameraDevices = (open: boolean) => { + const [availableCameras, setAvailableCameras] = useState([]); + const isMountedRef = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const enumerateDevices = useCallback(async () => { + if (typeof navigator === "undefined" || !navigator.mediaDevices) return; + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoInputs = devices.filter( + (device) => device.kind === "videoinput" + ); + if (isMountedRef.current) { + setAvailableCameras(videoInputs); + } + } catch (err) { + console.error("Failed to enumerate devices", err); + } + }, []); + + useEffect(() => { + if (!open) return; + + enumerateDevices(); + + const handleDeviceChange = () => { + enumerateDevices(); + }; + + navigator.mediaDevices?.addEventListener("devicechange", handleDeviceChange); + + return () => { + navigator.mediaDevices?.removeEventListener( + "devicechange", + handleDeviceChange + ); + }; + }, [open, enumerateDevices]); + + return { + devices: availableCameras, + refresh: enumerateDevices, + }; +}; + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMediaPermission.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMediaPermission.ts new file mode 100644 index 0000000000..c7755fbf70 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMediaPermission.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +type MediaPermissionKind = "camera" | "microphone"; + +type MediaPermissionState = + | PermissionState + | "unsupported" + | "unknown"; + +const permissionNameMap: Record = { + camera: "camera", + microphone: "microphone", +}; + +const mediaConstraintsMap: Record = { + camera: { video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }, + microphone: { audio: true, video: false }, +}; + +export const useMediaPermission = ( + kind: MediaPermissionKind, + enabled: boolean +) => { + const [state, setState] = useState("unknown"); + const permissionStatusRef = useRef(null); + + const updateState = useCallback((next: MediaPermissionState) => { + setState((prev) => { + if (prev === next) return prev; + return next; + }); + }, []); + + const refreshPermission = useCallback(async () => { + if (!enabled) return; + if (typeof navigator === "undefined" || !navigator.permissions?.query) { + updateState("unsupported"); + return; + } + + try { + const descriptor = { + name: permissionNameMap[kind], + } as PermissionDescriptor; + + const permissionStatus = await navigator.permissions.query(descriptor); + if (permissionStatusRef.current) { + permissionStatusRef.current.onchange = null; + } + permissionStatusRef.current = permissionStatus; + + updateState(permissionStatus.state); + + permissionStatus.onchange = () => { + updateState(permissionStatus.state); + }; + } catch (error) { + updateState("unsupported"); + } + }, [enabled, kind, updateState]); + + useEffect(() => { + if (!enabled) return; + refreshPermission(); + + return () => { + if (permissionStatusRef.current) { + permissionStatusRef.current.onchange = null; + } + permissionStatusRef.current = null; + }; + }, [enabled, refreshPermission]); + + const requestPermission = useCallback(async () => { + if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) { + updateState("unsupported"); + return false; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia( + mediaConstraintsMap[kind] + ); + stream.getTracks().forEach((track) => track.stop()); + updateState("granted"); + await refreshPermission(); + return true; + } catch (error) { + if (error instanceof DOMException) { + if (error.name === "NotAllowedError" || error.name === "SecurityError") { + updateState("denied"); + } + } + throw error; + } + }, [kind, refreshPermission, updateState]); + + return { + state, + requestPermission, + }; +}; + + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMicrophoneDevices.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMicrophoneDevices.ts new file mode 100644 index 0000000000..e4d996bb36 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMicrophoneDevices.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export const useMicrophoneDevices = (open: boolean) => { + const [availableMics, setAvailableMics] = useState([]); + const isMountedRef = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const enumerateDevices = useCallback(async () => { + if (typeof navigator === "undefined" || !navigator.mediaDevices) return; + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices.filter( + (device) => device.kind === "audioinput" + ); + if (isMountedRef.current) { + setAvailableMics(audioInputs); + } + } catch (err) { + console.error("Failed to enumerate devices", err); + } + }, []); + + useEffect(() => { + if (!open) return; + + enumerateDevices(); + + const handleDeviceChange = () => { + enumerateDevices(); + }; + + navigator.mediaDevices?.addEventListener("devicechange", handleDeviceChange); + + return () => { + navigator.mediaDevices?.removeEventListener( + "devicechange", + handleDeviceChange + ); + }; + }, [open, enumerateDevices]); + + return { + devices: availableMics, + refresh: enumerateDevices, + }; +}; + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts new file mode 100644 index 0000000000..39935f9681 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts @@ -0,0 +1,766 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { Organisation } from "@cap/web-domain"; +import { Option } from "effect"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; +import { createVideoAndGetUploadUrl } from "@/actions/video/upload"; +import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest"; +import { EffectRuntime, useRpcClient } from "@/lib/EffectRuntime"; +import { useUploadingContext } from "../../UploadingContext"; +import { sendProgressUpdate } from "../sendProgressUpdate"; +import type { + PresignedPost, + RecorderErrorEvent, + RecorderPhase, + VideoId, +} from "./web-recorder-types"; +import type { RecordingMode } from "./RecordingModeSelector"; + +interface UseWebRecorderOptions { + organisationId: string | undefined; + selectedMicId: string | null; + micEnabled: boolean; + recordingMode: RecordingMode; + selectedCameraId: string | null; + onPhaseChange?: (phase: RecorderPhase) => void; +} + +const DISPLAY_MEDIA_VIDEO_CONSTRAINTS: MediaTrackConstraints = { + frameRate: { ideal: 30 }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, +}; + +type ExtendedDisplayMediaStreamOptions = DisplayMediaStreamOptions & { + monitorTypeSurfaces?: "include" | "exclude"; + surfaceSwitching?: "include" | "exclude"; + selfBrowserSurface?: "include" | "exclude"; + preferCurrentTab?: boolean; +}; + +const DISPLAY_MODE_PREFERENCES: Record< + Exclude, + Partial +> = { + fullscreen: { + monitorTypeSurfaces: "include", + selfBrowserSurface: "exclude", + surfaceSwitching: "exclude", + }, + window: { + monitorTypeSurfaces: "exclude", + selfBrowserSurface: "exclude", + surfaceSwitching: "exclude", + }, + tab: { + monitorTypeSurfaces: "exclude", + selfBrowserSurface: "include", + surfaceSwitching: "exclude", + preferCurrentTab: true, + }, +}; + +const shouldRetryDisplayMediaWithoutPreferences = (error: unknown) => { + if (error instanceof DOMException) { + return ( + error.name === "OverconstrainedError" || + error.name === "NotSupportedError" + ); + } + + return error instanceof TypeError; +}; + +export const useWebRecorder = ({ + organisationId, + selectedMicId, + micEnabled, + recordingMode, + selectedCameraId, + onPhaseChange, +}: UseWebRecorderOptions) => { + const [phase, setPhase] = useState("idle"); + const [durationMs, setDurationMs] = useState(0); + const [videoId, setVideoId] = useState(null); + const [hasAudioTrack, setHasAudioTrack] = useState(false); + const [isSettingUp, setIsSettingUp] = useState(false); + + const mediaRecorderRef = useRef(null); + const recordedChunksRef = useRef([]); + const displayStreamRef = useRef(null); + const cameraStreamRef = useRef(null); + const micStreamRef = useRef(null); + const mixedStreamRef = useRef(null); + const videoRef = useRef(null); + const timerRef = useRef(null); + const startTimeRef = useRef(null); + const dimensionsRef = useRef<{ width?: number; height?: number }>({}); + const stopPromiseResolverRef = useRef<((blob: Blob) => void) | null>(null); + const stopPromiseRejectRef = useRef<((reason?: unknown) => void) | null>( + null + ); + const stopRecordingRef = useRef<(() => Promise) | null>(null); + + const rpc = useRpcClient(); + const router = useRouter(); + const { setUploadStatus } = useUploadingContext(); + const queryClient = useQueryClient(); + + const updatePhase = useCallback( + (newPhase: RecorderPhase) => { + setPhase(newPhase); + onPhaseChange?.(newPhase); + }, + [onPhaseChange] + ); + + const cleanupStreams = useCallback(() => { + const stopTracks = (stream: MediaStream | null) => { + stream?.getTracks().forEach((track) => { + track.stop(); + }); + }; + stopTracks(displayStreamRef.current); + stopTracks(cameraStreamRef.current); + stopTracks(micStreamRef.current); + stopTracks(mixedStreamRef.current); + displayStreamRef.current = null; + cameraStreamRef.current = null; + micStreamRef.current = null; + mixedStreamRef.current = null; + + if (videoRef.current) { + videoRef.current.srcObject = null; + } + }, []); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const resetState = useCallback(() => { + cleanupStreams(); + clearTimer(); + mediaRecorderRef.current = null; + recordedChunksRef.current = []; + setDurationMs(0); + updatePhase("idle"); + setVideoId(null); + setHasAudioTrack(false); + setUploadStatus(undefined); + }, [cleanupStreams, clearTimer, setUploadStatus, updatePhase]); + + useEffect(() => { + return () => { + resetState(); + }; + }, [resetState]); + + const stopRecordingInternal = useCallback(async () => { + const recorder = mediaRecorderRef.current; + if (!recorder || recorder.state === "inactive") return null; + + const stopPromise = new Promise((resolve, reject) => { + stopPromiseResolverRef.current = resolve; + stopPromiseRejectRef.current = reject; + }); + + recorder.stop(); + cleanupStreams(); + clearTimer(); + + return stopPromise; + }, [cleanupStreams, clearTimer]); + + const onRecorderDataAvailable = useCallback((event: BlobEvent) => { + if (event.data && event.data.size > 0) { + recordedChunksRef.current.push(event.data); + } + }, []); + + const onRecorderStop = useCallback(() => { + if (recordedChunksRef.current.length === 0) { + stopPromiseRejectRef.current?.(new Error("No recorded data")); + stopPromiseResolverRef.current = null; + stopPromiseRejectRef.current = null; + return; + } + + const blob = new Blob(recordedChunksRef.current, { + type: recordedChunksRef.current[0]?.type ?? "video/webm;codecs=vp8,opus", + }); + recordedChunksRef.current = []; + stopPromiseResolverRef.current?.(blob); + stopPromiseResolverRef.current = null; + stopPromiseRejectRef.current = null; + }, []); + + const onRecorderError = useCallback((event: RecorderErrorEvent) => { + const error = event.error ?? new DOMException("Recording error"); + stopPromiseRejectRef.current?.(error); + stopPromiseResolverRef.current = null; + stopPromiseRejectRef.current = null; + }, []); + + const captureThumbnail = useCallback( + (source: Blob) => + new Promise((resolve) => { + const video = document.createElement("video"); + const objectUrl = URL.createObjectURL(source); + video.src = objectUrl; + video.muted = true; + video.playsInline = true; + + let timeoutId: number; + + const cleanup = () => { + video.pause(); + video.removeAttribute("src"); + video.load(); + URL.revokeObjectURL(objectUrl); + }; + + const finalize = (result: Blob | null) => { + window.clearTimeout(timeoutId); + cleanup(); + resolve(result); + }; + + timeoutId = window.setTimeout(() => finalize(null), 10000); + + video.addEventListener( + "error", + () => { + finalize(null); + }, + { once: true } + ); + + video.addEventListener( + "loadedmetadata", + () => { + try { + const duration = Number.isFinite(video.duration) + ? video.duration + : 0; + const targetTime = duration > 0 ? Math.min(1, duration / 4) : 0; + video.currentTime = targetTime; + } catch { + finalize(null); + } + }, + { once: true } + ); + + video.addEventListener( + "seeked", + () => { + try { + const canvas = document.createElement("canvas"); + const width = + video.videoWidth || + dimensionsRef.current.width || + 640; + const height = + video.videoHeight || + dimensionsRef.current.height || + 360; + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + finalize(null); + return; + } + ctx.drawImage(video, 0, 0, width, height); + canvas.toBlob( + (blob) => { + finalize(blob ?? null); + }, + "image/jpeg", + 0.8 + ); + } catch { + finalize(null); + } + }, + { once: true } + ); + }), + [] + ); + + const convertToMp4 = useCallback( + async (blob: Blob, hasAudio: boolean, currentVideoId: string) => { + updatePhase("converting"); + setUploadStatus({ + status: "converting", + capId: currentVideoId, + progress: 0, + }); + + const file = new File([blob], "recording.webm", { type: blob.type }); + const { convertMedia } = await import("@remotion/webcodecs"); + + const result = await convertMedia({ + src: file, + container: "mp4", + videoCodec: "h264", + ...(hasAudio ? { audioCodec: "aac" as const } : {}), + onProgress: ({ overallProgress }) => { + if (overallProgress !== null) { + const percent = Math.min(100, Math.max(0, overallProgress * 100)); + setUploadStatus({ + status: "converting", + capId: currentVideoId, + progress: percent, + }); + } + }, + }); + + const savedFile = await result.save(); + if (savedFile.size === 0) { + throw new Error("Conversion produced empty file"); + } + if (savedFile.type !== "video/mp4") { + return new File([savedFile], "result.mp4", { type: "video/mp4" }); + } + return savedFile; + }, + [updatePhase, setUploadStatus] + ); + + const uploadRecording = useCallback( + async ( + blob: Blob, + upload: PresignedPost, + currentVideoId: string, + thumbnailPreviewUrl: string | undefined + ) => + new Promise((resolve, reject) => { + if (blob.size === 0) { + reject(new Error("Cannot upload empty file")); + return; + } + + const fileBlob = + blob instanceof File && blob.type === "video/mp4" + ? blob + : new File([blob], "result.mp4", { type: "video/mp4" }); + + console.log("Uploading file:", { + size: fileBlob.size, + type: fileBlob.type, + name: fileBlob.name, + uploadUrl: upload.url, + uploadFields: upload.fields, + }); + + const formData = new FormData(); + Object.entries(upload.fields).forEach(([key, value]) => { + formData.append(key, value); + }); + formData.append("file", fileBlob, "result.mp4"); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", upload.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + setUploadStatus({ + status: "uploadingVideo", + capId: currentVideoId, + progress: percent, + thumbnailUrl: thumbnailPreviewUrl, + }); + sendProgressUpdate(currentVideoId, event.loaded, event.total); + } + }; + + xhr.onload = async () => { + if (xhr.status >= 200 && xhr.status < 300) { + await sendProgressUpdate(currentVideoId, blob.size, blob.size); + resolve(); + } else { + const errorText = xhr.responseText || xhr.statusText || "Unknown error"; + console.error("Upload failed:", { + status: xhr.status, + statusText: xhr.statusText, + responseText: errorText, + }); + reject( + new Error( + `Upload failed with status ${xhr.status}: ${errorText}` + ) + ); + } + }; + + xhr.onerror = () => { + reject(new Error("Upload failed due to network error")); + }; + + xhr.send(formData); + }), + [setUploadStatus] + ); + + const startRecording = async () => { + if (!organisationId) { + toast.error("Select an organization before recording."); + return; + } + + if (recordingMode === "camera" && !selectedCameraId) { + toast.error("Select a camera before recording."); + return; + } + + setIsSettingUp(true); + + try { + let videoStream: MediaStream | null = null; + let firstTrack: MediaStreamTrack | null = null; + + if (recordingMode === "camera") { + if (!selectedCameraId) { + throw new Error("Camera ID is required for camera-only mode"); + } + videoStream = await navigator.mediaDevices.getUserMedia({ + video: { + deviceId: { exact: selectedCameraId }, + frameRate: { ideal: 30 }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + }); + cameraStreamRef.current = videoStream; + firstTrack = videoStream.getVideoTracks()[0] ?? null; + } else { + const baseDisplayRequest: DisplayMediaStreamOptions = { + video: { ...DISPLAY_MEDIA_VIDEO_CONSTRAINTS }, + audio: false, + }; + + const preferredOptions = DISPLAY_MODE_PREFERENCES[recordingMode]; + + if (preferredOptions) { + const preferredDisplayRequest: DisplayMediaStreamOptions = { + ...baseDisplayRequest, + ...preferredOptions, + }; + + try { + videoStream = await navigator.mediaDevices.getDisplayMedia( + preferredDisplayRequest + ); + } catch (displayError) { + if (shouldRetryDisplayMediaWithoutPreferences(displayError)) { + console.warn( + "Display media preferences not supported, retrying without them", + displayError + ); + videoStream = await navigator.mediaDevices.getDisplayMedia( + baseDisplayRequest + ); + } else { + throw displayError; + } + } + } + + if (!videoStream) { + videoStream = await navigator.mediaDevices.getDisplayMedia( + baseDisplayRequest + ); + } + displayStreamRef.current = videoStream; + firstTrack = videoStream.getVideoTracks()[0] ?? null; + } + + const settings = firstTrack?.getSettings(); + dimensionsRef.current = { + width: settings?.width || undefined, + height: settings?.height || undefined, + }; + + let micStream: MediaStream | null = null; + if (micEnabled && selectedMicId) { + try { + micStream = await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId: { exact: selectedMicId }, + echoCancellation: true, + autoGainControl: true, + noiseSuppression: true, + }, + }); + } catch (micError) { + console.warn("Microphone permission denied", micError); + toast.warning("Microphone unavailable. Recording without audio."); + micStream = null; + } + } + + if (micStream) { + micStreamRef.current = micStream; + } + + const mixedStream = new MediaStream([ + ...videoStream.getVideoTracks(), + ...(micStream ? micStream.getAudioTracks() : []), + ]); + + mixedStreamRef.current = mixedStream; + setHasAudioTrack(mixedStream.getAudioTracks().length > 0); + + recordedChunksRef.current = []; + + const mimeTypeCandidates = [ + "video/webm;codecs=vp9,opus", + "video/webm;codecs=vp8,opus", + "video/webm", + ]; + const mimeType = mimeTypeCandidates.find((candidate) => + MediaRecorder.isTypeSupported(candidate) + ); + + const recorder = new MediaRecorder( + mixedStream, + mimeType ? { mimeType } : undefined + ); + recorder.ondataavailable = onRecorderDataAvailable; + recorder.onstop = onRecorderStop; + recorder.onerror = onRecorderError; + + const handleVideoEnded = () => { + stopRecordingRef.current?.().catch(() => { + /* ignore */ + }); + }; + + firstTrack?.addEventListener("ended", handleVideoEnded, { once: true }); + + mediaRecorderRef.current = recorder; + recorder.start(200); + + startTimeRef.current = performance.now(); + setDurationMs(0); + updatePhase("recording"); + + timerRef.current = window.setInterval(() => { + if (startTimeRef.current !== null) + setDurationMs(performance.now() - startTimeRef.current); + }, 250); + } catch (err) { + console.error("Failed to start recording", err); + toast.error("Could not start recording."); + resetState(); + } finally { + setIsSettingUp(false); + } + }; + + const stopRecording = useCallback(async () => { + if (phase !== "recording") return; + + let createdVideoId: VideoId | null = null; + const orgId = organisationId; + if (!orgId) { + updatePhase("error"); + return; + } + + const brandedOrgId = Organisation.OrganisationId.make(orgId); + + let thumbnailBlob: Blob | null = null; + let thumbnailPreviewUrl: string | undefined; + + try { + updatePhase("creating"); + + const blob = await stopRecordingInternal(); + if (!blob) { + throw new Error("No recording available"); + } + + const durationSeconds = Math.max(1, Math.round(durationMs / 1000)); + const width = dimensionsRef.current.width; + const height = dimensionsRef.current.height; + const resolution = width && height ? `${width}x${height}` : undefined; + + setUploadStatus({ status: "creating" }); + + const result = await EffectRuntime.runPromise( + rpc.VideoInstantCreate({ + orgId: brandedOrgId, + folderId: Option.none(), + resolution, + durationSeconds, + width, + height, + videoCodec: "h264", + audioCodec: hasAudioTrack ? "aac" : undefined, + supportsUploadProgress: true, + }) + ); + + createdVideoId = result.id; + setVideoId(result.id); + + const mp4Blob = await convertToMp4(blob, hasAudioTrack, result.id); + + thumbnailBlob = await captureThumbnail(mp4Blob); + thumbnailPreviewUrl = thumbnailBlob + ? URL.createObjectURL(thumbnailBlob) + : undefined; + + updatePhase("uploading"); + setUploadStatus({ + status: "uploadingVideo", + capId: result.id, + progress: 0, + thumbnailUrl: thumbnailPreviewUrl, + }); + + await uploadRecording(mp4Blob, result.upload, result.id, thumbnailPreviewUrl); + + if (thumbnailBlob) { + try { + const screenshotData = await createVideoAndGetUploadUrl({ + videoId: result.id, + isScreenshot: true, + orgId: brandedOrgId, + }); + + const screenshotFormData = new FormData(); + Object.entries(screenshotData.presignedPostData.fields).forEach( + ([key, value]) => { + screenshotFormData.append(key, value as string); + } + ); + screenshotFormData.append( + "file", + thumbnailBlob, + "screen-capture.jpg" + ); + + setUploadStatus({ + status: "uploadingThumbnail", + capId: result.id, + progress: 90, + }); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", screenshotData.presignedPostData.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = 90 + (event.loaded / event.total) * 10; + setUploadStatus({ + status: "uploadingThumbnail", + capId: result.id, + progress: percent, + }); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject( + new Error( + `Screenshot upload failed with status ${xhr.status}` + ) + ); + } + }; + + xhr.onerror = () => { + reject(new Error("Screenshot upload failed")); + }; + + xhr.send(screenshotFormData); + }); + + queryClient.refetchQueries({ + queryKey: ThumbnailRequest.queryKey(result.id), + }); + } catch (thumbnailError) { + console.error("Failed to upload thumbnail", thumbnailError); + toast.warning( + "Recording uploaded, but thumbnail failed to upload." + ); + } + } + + setUploadStatus(undefined); + updatePhase("completed"); + toast.success("Recording uploaded"); + router.refresh(); + } catch (err) { + console.error("Failed to process recording", err); + setUploadStatus(undefined); + updatePhase("error"); + + const idToDelete = createdVideoId ?? videoId; + if (idToDelete) { + EffectRuntime.runPromise(rpc.VideoDelete(idToDelete)).catch(() => { + /* ignore */ + }); + } + } finally { + if (thumbnailPreviewUrl) { + URL.revokeObjectURL(thumbnailPreviewUrl); + } + } + }, [ + phase, + organisationId, + durationMs, + hasAudioTrack, + videoId, + updatePhase, + setUploadStatus, + rpc, + router, + convertToMp4, + uploadRecording, + stopRecordingInternal, + captureThumbnail, + queryClient, + ]); + + useEffect(() => { + stopRecordingRef.current = stopRecording; + }, [stopRecording]); + + return { + phase, + durationMs, + videoId, + hasAudioTrack, + isSettingUp, + isRecording: phase === "recording", + isBusy: + phase === "recording" || + phase === "creating" || + phase === "converting" || + phase === "uploading", + canStartRecording: Boolean(organisationId) && !isSettingUp, + startRecording, + stopRecording, + resetState, + }; +}; + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-constants.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-constants.ts new file mode 100644 index 0000000000..52a83e7d9a --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-constants.ts @@ -0,0 +1,32 @@ +export const NO_MICROPHONE = "No Microphone"; +export const NO_MICROPHONE_VALUE = "__no_microphone__"; +export const NO_CAMERA = "No Camera"; +export const NO_CAMERA_VALUE = "__no_camera__"; + +export const dialogVariants = { + hidden: { + opacity: 0, + scale: 0.9, + y: 20, + }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: "spring", + duration: 0.4, + damping: 25, + stiffness: 500, + }, + }, + exit: { + opacity: 0, + scale: 0.95, + y: 10, + transition: { + duration: 0.2, + }, + }, +}; + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-types.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-types.ts new file mode 100644 index 0000000000..9675401db2 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-types.ts @@ -0,0 +1,15 @@ +export type RecorderPhase = + | "idle" + | "recording" + | "creating" + | "converting" + | "uploading" + | "completed" + | "error"; + +export type RecorderErrorEvent = Event & { error?: DOMException }; + +type VideoNamespace = typeof import("@cap/web-domain").Video; +export type PresignedPost = VideoNamespace["PresignedPost"]["Type"]; +export type VideoId = VideoNamespace["VideoId"]["Type"]; + diff --git a/apps/web/app/(org)/dashboard/caps/components/index.ts b/apps/web/app/(org)/dashboard/caps/components/index.ts index ced28c433b..44d2b5579f 100644 --- a/apps/web/app/(org)/dashboard/caps/components/index.ts +++ b/apps/web/app/(org)/dashboard/caps/components/index.ts @@ -5,3 +5,4 @@ export * from "./NewFolderDialog"; export * from "./SelectedCapsBar"; export * from "./UploadCapButton"; export * from "./UploadPlaceholderCard"; +export * from "./WebRecorderDialog/WebRecorderDialog"; diff --git a/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts b/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts new file mode 100644 index 0000000000..edf31eb377 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts @@ -0,0 +1,23 @@ +import { EffectRuntime } from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; + +export const sendProgressUpdate = async ( + videoId: string, + uploaded: number, + total: number, +) => { + try { + await EffectRuntime.runPromise( + withRpc((rpc) => + rpc.VideoUploadProgressUpdate({ + videoId, + uploaded, + total, + updatedAt: new Date(), + }), + ), + ); + } catch (error) { + console.error("Failed to send progress update:", error); + } +}; diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index 8f843fa20b..a7880e135a 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -11,47 +11,90 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( return { VideoDelete: (videoId) => videos.delete(videoId).pipe( - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - S3Error: () => new InternalError({ type: "s3" }), - }), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "S3Error", + () => new InternalError({ type: "s3" }), + ), ), VideoDuplicate: (videoId) => videos.duplicate(videoId).pipe( - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - S3Error: () => new InternalError({ type: "s3" }), - }), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "S3Error", + () => new InternalError({ type: "s3" }), + ), ), GetUploadProgress: (videoId) => videos.getUploadProgress(videoId).pipe( provideOptionalAuth, - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - UnknownException: () => new InternalError({ type: "unknown" }), - }), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "UnknownException", + () => new InternalError({ type: "unknown" }), + ), + ), + + VideoInstantCreate: (input) => + videos.createInstantRecording(input).pipe( + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "S3Error", + () => new InternalError({ type: "s3" }), + ), + ), + + VideoUploadProgressUpdate: (input) => + videos.updateUploadProgress(input).pipe( + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), ), VideoGetDownloadInfo: (videoId) => videos.getDownloadInfo(videoId).pipe( provideOptionalAuth, - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - UnknownException: () => new InternalError({ type: "unknown" }), - S3Error: () => new InternalError({ type: "s3" }), - }), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "UnknownException", + () => new InternalError({ type: "unknown" }), + ), + Effect.catchTag( + "S3Error", + () => new InternalError({ type: "s3" }), + ), ), VideosGetThumbnails: (videoIds) => Effect.all( videoIds.map((id) => videos.getThumbnailURL(id).pipe( - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - S3Error: () => new InternalError({ type: "s3" }), - }), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "S3Error", + () => new InternalError({ type: "s3" }), + ), Effect.matchEffect({ onSuccess: (v) => Effect.succeed(Exit.succeed(v)), onFailure: (e) => @@ -65,20 +108,28 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( { concurrency: 10 }, ).pipe( provideOptionalAuth, - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - UnknownException: () => new InternalError({ type: "unknown" }), - }), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "UnknownException", + () => new InternalError({ type: "unknown" }), + ), ), VideosGetAnalytics: (videoIds) => Effect.all( videoIds.map((id) => videos.getAnalytics(id).pipe( - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - UnknownException: () => new InternalError({ type: "unknown" }), - }), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "UnknownException", + () => new InternalError({ type: "unknown" }), + ), Effect.matchEffect({ onSuccess: (v) => Effect.succeed(Exit.succeed(v)), onFailure: (e) => @@ -92,10 +143,14 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( { concurrency: 10 }, ).pipe( provideOptionalAuth, - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - UnknownException: () => new InternalError({ type: "unknown" }), - }), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchTag( + "UnknownException", + () => new InternalError({ type: "unknown" }), + ), ), }; }), diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index b85a464e7e..f58c02ab74 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -1,14 +1,23 @@ import * as Db from "@cap/database/schema"; +import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub } from "@cap/utils"; -import { CurrentUser, Policy, Video } from "@cap/web-domain"; +import { CurrentUser, Folder, Policy, Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Array, Effect, Option, pipe } from "effect"; +import type { Schema } from "effect/Schema"; import { Database } from "../Database.ts"; import { S3Buckets } from "../S3Buckets/index.ts"; import { VideosPolicy } from "./VideosPolicy.ts"; import { VideosRepo } from "./VideosRepo.ts"; +type UploadProgressUpdateInput = Schema.Type< + typeof Video.UploadProgressUpdateInput +>; +type InstantRecordingCreateInput = Schema.Type< + typeof Video.InstantRecordingCreateInput +>; + export class Videos extends Effect.Service()("Videos", { effect: Effect.gen(function* () { const db = yield* Database; @@ -131,6 +140,171 @@ export class Videos extends Effect.Service()("Videos", { ); }), + updateUploadProgress: Effect.fn("Videos.updateUploadProgress")(function* ( + input: UploadProgressUpdateInput, + ) { + const user = yield* CurrentUser; + + const uploaded = Math.min(input.uploaded, input.total); + const total = input.total; + const updatedAt = input.updatedAt; + const videoId = input.videoId; + + const [record] = yield* db.use((db) => + db + .select({ + video: Db.videos, + upload: Db.videoUploads, + }) + .from(Db.videos) + .leftJoin( + Db.videoUploads, + Dz.eq(Db.videos.id, Db.videoUploads.videoId), + ) + .where( + Dz.and( + Dz.eq(Db.videos.id, videoId), + Dz.eq(Db.videos.ownerId, user.id), + ), + ), + ); + + if (!record) return yield* new Video.NotFoundError(); + + yield* db.use((db) => + db.transaction(async (tx) => { + if (record.upload) { + if (uploaded === total && record.upload.mode === "singlepart") { + await tx + .delete(Db.videoUploads) + .where(Dz.eq(Db.videoUploads.videoId, videoId)); + return; + } + + await tx + .update(Db.videoUploads) + .set({ + uploaded, + total, + updatedAt, + }) + .where( + Dz.and( + Dz.eq(Db.videoUploads.videoId, videoId), + Dz.lte(Db.videoUploads.updatedAt, updatedAt), + ), + ); + return; + } + + await tx.insert(Db.videoUploads).values({ + videoId, + uploaded, + total, + updatedAt, + }); + }), + ); + + return true as const; + }), + + createInstantRecording: Effect.fn("Videos.createInstantRecording")( + function* (input: InstantRecordingCreateInput) { + const user = yield* CurrentUser; + + if (user.activeOrganizationId !== input.orgId) + return yield* new Policy.PolicyDeniedError(); + + const [customBucket] = yield* db.use((db) => + db + .select() + .from(Db.s3Buckets) + .where(Dz.eq(Db.s3Buckets.ownerId, user.id)), + ); + + const bucketId = Option.fromNullable(customBucket?.id); + const folderId = input.folderId ?? Option.none(); + const width = Option.fromNullable(input.width); + const height = Option.fromNullable(input.height); + const duration = Option.fromNullable(input.durationSeconds); + + const now = new Date(); + const formattedDate = `${now.getDate()} ${now.toLocaleString("default", { + month: "long", + })} ${now.getFullYear()}`; + + const videoId = yield* repo.create({ + ownerId: user.id, + orgId: input.orgId, + name: `Cap Recording - ${formattedDate}`, + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + source: { type: "desktopMP4" as const }, + bucketId, + folderId, + width, + height, + duration, + metadata: Option.none(), + transcriptionStatus: Option.none(), + }); + + if (input.supportsUploadProgress ?? true) + yield* db.use((db) => + db.insert(Db.videoUploads).values({ + videoId, + mode: "singlepart", + }), + ); + + const fileKey = `${user.id}/${videoId}/result.mp4`; + const [bucket] = yield* s3Buckets.getBucketAccess(bucketId); + const presignedPostData = yield* bucket.getPresignedPostUrl( + fileKey, + { + Fields: { + "Content-Type": "video/mp4", + "x-amz-meta-userid": user.id, + "x-amz-meta-duration": input.durationSeconds + ? input.durationSeconds.toString() + : "", + "x-amz-meta-resolution": input.resolution ?? "", + "x-amz-meta-videocodec": input.videoCodec ?? "", + "x-amz-meta-audiocodec": input.audioCodec ?? "", + }, + Expires: 1800, + }, + ); + + const shareUrl = `${serverEnv().WEB_URL}/s/${videoId}`; + + if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") + yield* Effect.tryPromise(() => + dub() + .links.create({ + url: shareUrl, + domain: "cap.link", + key: videoId, + }), + ).pipe( + Effect.catchAll((error) => + Effect.logWarning( + `Dub link create failed: ${String(error)}`, + ), + ), + ); + + return { + id: videoId, + shareUrl, + upload: { + url: presignedPostData.url, + fields: presignedPostData.fields, + }, + }; + }, + ), + create: Effect.fn("Videos.create")(repo.create), getDownloadInfo: Effect.fn("Videos.getDownloadInfo")(function* ( diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 98d8c633bf..8247836341 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -67,6 +67,36 @@ export class UploadProgress extends Schema.Class( updatedAt: Schema.Date, }) {} +export const UploadProgressUpdateInput = Schema.Struct({ + videoId: VideoId, + uploaded: Schema.Int.pipe(Schema.greaterThanOrEqualTo(0)), + total: Schema.Int.pipe(Schema.greaterThanOrEqualTo(0)), + updatedAt: Schema.Date, +}); + +export const PresignedPost = Schema.Struct({ + url: Schema.String, + fields: Schema.Record({ key: Schema.String, value: Schema.String }), +}); + +export const InstantRecordingCreateInput = Schema.Struct({ + orgId: OrganisationId, + folderId: Schema.OptionFromUndefinedOr(FolderId), + durationSeconds: Schema.optional(Schema.Number), + resolution: Schema.optional(Schema.String), + width: Schema.optional(Schema.Number), + height: Schema.optional(Schema.Number), + videoCodec: Schema.optional(Schema.String), + audioCodec: Schema.optional(Schema.String), + supportsUploadProgress: Schema.optional(Schema.Boolean), +}); + +export const InstantRecordingCreateSuccess = Schema.Struct({ + id: VideoId, + shareUrl: Schema.String, + upload: PresignedPost, +}); + export class ImportSource extends Schema.Class("ImportSource")({ source: Schema.Literal("loom"), id: Schema.String, @@ -157,6 +187,20 @@ export class VideoRpcs extends RpcGroup.make( VerifyVideoPasswordError, ), }), + Rpc.make("VideoInstantCreate", { + payload: InstantRecordingCreateInput, + success: InstantRecordingCreateSuccess, + error: Schema.Union(InternalError, PolicyDeniedError), + }).middleware(RpcAuthMiddleware), + Rpc.make("VideoUploadProgressUpdate", { + payload: UploadProgressUpdateInput, + success: Schema.Boolean, + error: Schema.Union( + NotFoundError, + InternalError, + PolicyDeniedError, + ), + }).middleware(RpcAuthMiddleware), Rpc.make("VideoGetDownloadInfo", { payload: VideoId, success: Schema.Option( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a08cf69e61..7a134c774a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: version: 0.14.10(solid-js@1.9.6) '@solidjs/start': specifier: ^1.1.3 - version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) + version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) '@tanstack/solid-query': specifier: ^5.51.21 version: 5.75.4(solid-js@1.9.6) @@ -202,7 +202,7 @@ importers: version: 9.0.1 vinxi: specifier: ^0.5.6 - version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) webcodecs: specifier: ^0.1.0 version: 0.1.0 @@ -342,88 +342,6 @@ importers: specifier: ^2.10.2 version: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) - apps/tasks: - dependencies: - '@types/cors': - specifier: ^2.8.17 - version: 2.8.17 - '@types/express': - specifier: ^4.17.21 - version: 4.17.21 - '@types/fluent-ffmpeg': - specifier: ^2.1.24 - version: 2.1.27 - '@types/jest': - specifier: ^29.5.12 - version: 29.5.14 - '@types/morgan': - specifier: ^1.9.9 - version: 1.9.9 - '@types/node': - specifier: ^20.12.6 - version: 20.17.43 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.3 - cors: - specifier: ^2.8.5 - version: 2.8.5 - dotenv: - specifier: ^16.4.5 - version: 16.5.0 - express: - specifier: ^4.19.2 - version: 4.21.2 - fluent-ffmpeg: - specifier: ^2.1.3 - version: 2.1.3 - helmet: - specifier: ^7.1.0 - version: 7.2.0 - morgan: - specifier: ^1.10.0 - version: 1.10.0 - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@20.17.43)(typescript@5.8.3) - typescript: - specifier: ^5.8.3 - version: 5.8.3 - zod: - specifier: ^3 - version: 3.25.76 - devDependencies: - '@typescript-eslint/eslint-plugin': - specifier: ^7.6.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/parser': - specifier: ^7.6.0 - version: 7.18.0(eslint@8.57.1)(typescript@5.8.3) - eslint: - specifier: ^8.57.0 - version: 8.57.1 - eslint-config-airbnb-typescript: - specifier: ^18.0.0 - version: 18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-import-resolver-typescript: - specifier: ^3.6.1 - version: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: - specifier: ^2.29.1 - version: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - nodemon: - specifier: ^3.1.0 - version: 3.1.10 - supertest: - specifier: ^6.3.4 - version: 6.3.4 - ts-jest: - specifier: ^29.1.2 - version: 29.3.2(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(jest@29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)))(typescript@5.8.3) - apps/web: dependencies: '@aws-sdk/client-cloudfront': @@ -704,7 +622,7 @@ importers: version: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-contentlayer2: specifier: ^0.5.3 version: 0.5.8(acorn@8.15.0)(contentlayer2@0.5.8(acorn@8.15.0)(esbuild@0.25.5))(esbuild@0.25.5)(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1017,7 +935,7 @@ importers: version: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-email: specifier: ^4.0.16 version: 4.0.16(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1822,91 +1740,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.27.1': resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} engines: {node: '>=6.9.0'} @@ -1973,9 +1812,6 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@biomejs/biome@2.2.0': resolution: {integrity: sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw==} engines: {node: '>=14.21.3'} @@ -2359,9 +2195,6 @@ packages: '@effect/rpc': ^0.71.0 effect: ^3.18.1 - '@emnapi/core@1.4.3': - resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} - '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} @@ -2371,9 +2204,6 @@ packages: '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/wasi-threads@1.0.2': - resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==} - '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -3698,80 +3528,6 @@ packages: '@isaacs/string-locale-compare@1.1.0': resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==} - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/core@29.7.0': - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -4517,6 +4273,9 @@ packages: '@oxc-project/types@0.94.0': resolution: {integrity: sha512-+UgQT/4o59cZfH6Cp7G0hwmqEQ0wE+AdIwhikdwnhWI9Dp8CgSY081+Q3O67/wq3VJu8mgUEB93J9EHHn70fOw==} + '@oxc-project/types@0.95.0': + resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -5445,8 +5204,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-TP8bcPOb1s6UmY5syhXrDn9k0XkYcw+XaoylTN4cJxf0JOVS2j682I3aTcpfT51hOFGr2bRwNKN9RZ19XxeQbA==} + '@rolldown/binding-android-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -5457,8 +5216,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-kuVWnZsE4vEjMF/10SbSUyzucIW2zmdsqFghYMqy+fsjXnRHg0luTU6qWF8IqJf4Cbpm9NEZRnjIEPpAbdiSNQ==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -5469,8 +5228,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.43': - resolution: {integrity: sha512-u9Ps4sh6lcmJ3vgLtyEg/x4jlhI64U0mM93Ew+tlfFdLDe7yKyA+Fe80cpr2n1mNCeZXrvTSbZluKpXQ0GxLjw==} + '@rolldown/binding-darwin-x64@1.0.0-beta.45': + resolution: {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -5481,8 +5240,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.0.0-beta.43': - resolution: {integrity: sha512-h9lUtVtXgfbk/tnicMpbFfZ3DJvk5Zn2IvmlC1/e0+nUfwoc/TFqpfrRRqcNBXk/e+xiWMSKv6b0MF8N+Rtvlg==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': + resolution: {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -5493,8 +5252,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.43': - resolution: {integrity: sha512-IX2C6bA6wM2rX/RvD75ko+ix9yxPKjKGGq7pOhB8wGI4Z4fqX5B1nDHga/qMDmAdCAR1m9ymzxkmqhm/AFYf7A==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': + resolution: {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -5505,8 +5264,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.43': - resolution: {integrity: sha512-mcjd57vEj+CEQbZAzUiaxNzNgwwgOpFtZBWcINm8DNscvkXl5b/s622Z1dqGNWSdrZmdjdC6LWMvu8iHM6v9sQ==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': + resolution: {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -5517,8 +5276,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.43': - resolution: {integrity: sha512-Pa8QMwlkrztTo/1mVjZmPIQ44tCSci10TBqxzVBvXVA5CFh5EpiEi99fPSll2dHG2uT4dCOMeC6fIhyDdb0zXA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': + resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -5529,8 +5288,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.43': - resolution: {integrity: sha512-BgynXKMjeaX4AfWLARhOKDetBOOghnSiVRjAHVvhiAaDXgdQN8e65mSmXRiVoVtD3cHXx/cfU8Gw0p0K+qYKVQ==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': + resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -5541,8 +5300,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.43': - resolution: {integrity: sha512-VIsoPlOB/tDSAw9CySckBYysoIBqLeps1/umNSYUD8pMtalJyzMTneAVI1HrUdf4ceFmQ5vARoLIXSsPwVFxNg==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': + resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -5553,8 +5312,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-YDXTxVJG67PqTQMKyjVJSddoPbSWJ4yRz/E3xzTLHqNrTDGY0UuhG8EMr8zsYnfH/0cPFJ3wjQd/hJWHuR6nkA==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -5564,8 +5323,8 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.43': - resolution: {integrity: sha512-3M+2DmorXvDuAIGYQ9Z93Oy1G9ETkejLwdXXb1uRTgKN9pMcu7N+KG2zDrJwqyxeeLIFE22AZGtSJm3PJbNu9Q==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': + resolution: {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] @@ -5575,8 +5334,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-/B1j1pJs33y9ywtslOMxryUPHq8zIGu/OGEc2gyed0slimJ8fX2uR/SaJVhB4+NEgCFIeYDR4CX6jynAkeRuCA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -5587,8 +5346,8 @@ packages: cpu: [ia32] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-29oG1swCz7hNP+CQYrsM4EtylsKwuYzM8ljqbqC5TsQwmKat7P8ouDpImsqg/GZxFSXcPP9ezQm0Q0wQwGM3JA==} + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] @@ -5599,8 +5358,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-eWBV1Ef3gfGNehxVGCyXs7wLayRIgCmyItuCZwYYXW5bsk4EvR4n2GP5m3ohjnx7wdiY3nLmwQfH2Knb5gbNZw==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5608,8 +5367,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.42': resolution: {integrity: sha512-N7pQzk9CyE7q0bBN/q0J8s6Db279r5kUZc6d7/wWRe9/zXqC52HQovVyu6iXPIDY4BEzzgbVLhVFXrOuGJ22ZQ==} - '@rolldown/pluginutils@1.0.0-beta.43': - resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} + '@rolldown/pluginutils@1.0.0-beta.45': + resolution: {integrity: sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==} '@rollup/plugin-alias@5.1.1': resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} @@ -5837,9 +5596,6 @@ packages: resolution: {integrity: sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==} engines: {node: ^16.14.0 || >=18.0.0} - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -5852,12 +5608,6 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@smithy/abort-controller@4.0.2': resolution: {integrity: sha512-Sl/78VDtgqKxN2+1qduaVE140XF+Xg+TafkncspwM4jFP/LHr76ZHmIY/y3V1M0mMLNk+Je6IGbzxy23RSToMw==} engines: {node: '>=18.0.0'} @@ -6495,10 +6245,10 @@ packages: react-dom: optional: true - '@storybook/builder-vite@10.0.0-beta.13': - resolution: {integrity: sha512-ZPxqgoofi2yfJRQkkEBINSzPjkhrgfh4HsPs4fsbPt3jfVyfP4Wic7vHep4UlR1os2iL2MTRLcapkkiajoztWQ==} + '@storybook/builder-vite@10.1.0-alpha.3': + resolution: {integrity: sha512-bMgykL7QJCJREt0VyMBmTmEyxOkwssbOoKC6nMoJ8Ltnc8Sxr7g1nWSJyTnRvfrynCeUjJXQCKZOEw8LAzfSGA==} peerDependencies: - storybook: ^10.0.0-beta.13 + storybook: ^10.1.0-alpha.3 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 '@storybook/core@8.6.12': @@ -6509,12 +6259,12 @@ packages: prettier: optional: true - '@storybook/csf-plugin@10.0.0-beta.13': - resolution: {integrity: sha512-mjdDsl5MAn6bLZHHInut+TQjYUJDdErpXnYlXPLvZRoaCxqxLN2O2sf5LmxAP9SRjEdKnsLFvBwMCJxVcav/lQ==} + '@storybook/csf-plugin@10.1.0-alpha.3': + resolution: {integrity: sha512-0RBdc8R/iWdWTnHYvIfqWhdJWdRckLRPs2puu0xNOax3eX69zhPPXsx5XS6j1LCJYom3sNegX578HgkCu7snzg==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.0.0-beta.13 + storybook: ^10.1.0-alpha.3 vite: '*' webpack: '*' peerDependenciesMeta: @@ -7021,15 +6771,9 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - '@types/cookies@0.9.0': resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} - '@types/cors@2.8.17': - resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} - '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -7063,15 +6807,9 @@ packages: '@types/file-saver@2.0.7': resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} - '@types/fluent-ffmpeg@2.1.27': - resolution: {integrity: sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==} - '@types/google-protobuf@3.15.12': resolution: {integrity: sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==} - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -7084,18 +6822,6 @@ packages: '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - '@types/js-cookie@3.0.6': resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} @@ -7129,18 +6855,12 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - '@types/micromatch@4.0.9': resolution: {integrity: sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==} '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/morgan@1.9.9': - resolution: {integrity: sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -7219,15 +6939,6 @@ packages: '@types/shimmer@1.2.0': resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@6.0.3': - resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - '@types/tmp@0.2.6': resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} @@ -7249,12 +6960,6 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -7773,10 +7478,6 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -7874,9 +7575,6 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - array-includes@3.1.8: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} @@ -7915,9 +7613,6 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - asn1js@3.0.6: resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} engines: {node: '>=12.0.0'} @@ -7959,9 +7654,6 @@ packages: async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} - async@0.2.10: - resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -8007,12 +7699,6 @@ packages: babel-dead-code-elimination@1.0.10: resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} - babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - babel-loader@10.0.0: resolution: {integrity: sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==} engines: {node: ^18.20.0 || ^20.10.0 || >=22.0.0} @@ -8020,30 +7706,11 @@ packages: '@babel/core': ^7.12.0 webpack: '>=5.61.0' - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - - babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - babel-plugin-jsx-dom-expressions@0.39.8: resolution: {integrity: sha512-/MVOIIjonylDXnrWmG23ZX82m9mtKATsVHB7zYlPfDR9Vdd/NBE48if+wv27bSkBtyO7EPMUlcUc4J63QwuACQ==} peerDependencies: '@babel/core': ^7.20.12 - babel-preset-current-node-syntax@1.1.0: - resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==} - peerDependencies: - '@babel/core': ^7.0.0 - - babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 - babel-preset-solid@1.9.6: resolution: {integrity: sha512-HXTK9f93QxoH8dYn1M2mJdOlWgMsR88Lg/ul6QCZGkNTktjTE5HAf93YxQumHoCudLEtZrU1cFCMFOVho6GqFg==} peerDependencies: @@ -8069,10 +7736,6 @@ packages: resolution: {integrity: sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==} hasBin: true - basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -8109,10 +7772,6 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -8150,13 +7809,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -8240,14 +7892,6 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - camelcase@8.0.0: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} @@ -8300,10 +7944,6 @@ packages: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -8352,10 +7992,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -8425,16 +8061,9 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} - collect-v8-coverage@1.0.2: - resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} - color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -8503,9 +8132,6 @@ packages: compatx@0.2.0: resolution: {integrity: sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA==} - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - compress-commons@4.1.2: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} @@ -8528,9 +8154,6 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - confusing-browser-globals@1.0.11: - resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} - consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -8538,10 +8161,6 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -8564,9 +8183,6 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -8575,10 +8191,6 @@ packages: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -8587,9 +8199,6 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - cookies-next@4.3.0: resolution: {integrity: sha512-XxeCwLR30cWwRd94sa9X5lRCDLVujtx73tv+N0doQCFIDl83fuuYdxbu/WQUt9aSV7EJx7bkMvJldjvzuFqr4w==} @@ -8620,11 +8229,6 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} - create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -8888,10 +8492,6 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} @@ -8934,16 +8534,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -9148,21 +8741,12 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - electron-to-chromium@1.5.150: resolution: {integrity: sha512-rOOkP2ZUMx1yL4fCxXQKDHQ8ZXwisb2OycOQVKHgvB3ZI4CvehOd4y2tfnnLDieJ3Zs1RL1Dlp3cMkyIn7nnXA==} electron-to-chromium@1.5.234: resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==} - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -9231,9 +8815,6 @@ packages: err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -9330,10 +8911,6 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -9347,20 +8924,6 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-airbnb-base@15.0.0: - resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==} - engines: {node: ^10.12.0 || >=12.0.0} - peerDependencies: - eslint: ^7.32.0 || ^8.2.0 - eslint-plugin-import: ^2.25.2 - - eslint-config-airbnb-typescript@18.0.0: - resolution: {integrity: sha512-oc+Lxzgzsu8FQyFVa4QFaVKiitTYiiW3frB9KYW5OWdPrqFc7FzxgB20hP4cHMlr+MBzGcLl3jnCOVOydL9mIg==} - peerDependencies: - '@typescript-eslint/eslint-plugin': ^7.0.0 - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 - eslint-config-next@13.3.0: resolution: {integrity: sha512-6YEwmFBX0VjBd3ODGW9df0Is0FLaRFdMN8eAahQG9CN6LjQ28J8AFr19ngxqMSg7Qv6Uca/3VeeBosJh1bzu0w==} peerDependencies: @@ -9656,18 +9219,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} - expect-type@1.2.1: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - exponential-backoff@3.1.2: resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} @@ -9677,10 +9232,6 @@ packages: peerDependencies: express: '>= 4.11' - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} - engines: {node: '>= 0.10.0'} - express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -9733,9 +9284,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} @@ -9753,9 +9301,6 @@ packages: fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -9800,9 +9345,6 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} - filesize@10.1.6: resolution: {integrity: sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==} engines: {node: '>= 10.4.0'} @@ -9815,10 +9357,6 @@ packages: resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} engines: {node: '>=14.16'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} - engines: {node: '>= 0.8'} - finalhandler@2.1.0: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} @@ -9830,10 +9368,6 @@ packages: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -9860,11 +9394,6 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - fluent-ffmpeg@2.1.3: - resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} - engines: {node: '>=18'} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -9904,9 +9433,6 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} - formidable@2.1.5: - resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -10019,10 +9545,6 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - get-port-please@3.1.2: resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} @@ -10223,10 +9745,6 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - helmet@7.2.0: - resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} - engines: {node: '>=16.0.0'} - hls.js@0.14.17: resolution: {integrity: sha512-25A7+m6qqp6UVkuzUQ//VVh2EEOPYlOBg32ypr34bcPO7liBMOkKFvbjbCBfiPAOTA/7BSx1Dujft3Th57WyFg==} @@ -10258,9 +9776,6 @@ packages: html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-to-image@1.11.13: resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} @@ -10327,10 +9842,6 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -10345,9 +9856,6 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore-by-default@1.0.1: - resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} - ignore-walk@6.0.5: resolution: {integrity: sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -10378,11 +9886,6 @@ packages: import-in-the-middle@1.13.1: resolution: {integrity: sha512-k2V9wNm9B+ysuelDTHjI9d5KPc4l8zAZTGqj+pcynvWkypZd857ryzN8jNC7Pg2YZXNMJcHRPpaDyCBbNyVRpA==} - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -10471,9 +9974,6 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} @@ -10545,10 +10045,6 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - is-generator-function@1.1.0: resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} engines: {node: '>= 0.4'} @@ -10707,30 +10203,6 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} - - istanbul-reports@3.1.7: - resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} - engines: {node: '>=8'} - iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -10742,168 +10214,34 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} - jake@10.9.2: - resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} - engines: {node: '>=10'} - hasBin: true + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} - jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true - jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true - jest-cli@29.7.0: - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - jest-config@29.7.0: - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true + jmespath@0.16.0: + resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} + engines: {node: '>= 0.6.0'} - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} - jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} - - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest@29.7.0: - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - - jiti@2.4.2: - resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} - hasBin: true - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - jmespath@0.16.0: - resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} - engines: {node: '>= 0.6.0'} - - jose@4.15.9: - resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} - - jose@5.10.0: - resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - - jose@5.2.3: - resolution: {integrity: sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==} + jose@5.2.3: + resolution: {integrity: sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==} jose@5.6.3: resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==} @@ -11063,10 +10401,6 @@ packages: leb@1.0.0: resolution: {integrity: sha512-Y3c3QZfvKWHX60BVOQPhLCvVGmDYWyJEiINE3drOog6KCyN2AOwvuQQzlS3uJg1J85kzpILXIUwRXULWavir+w==} - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -11101,10 +10435,6 @@ packages: resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==} engines: {node: '>=14'} - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -11140,9 +10470,6 @@ packages: lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -11249,10 +10576,6 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -11260,9 +10583,6 @@ packages: resolution: {integrity: sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==} engines: {node: ^16.14.0 || >=18.0.0} - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - map-or-similar@1.5.0: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} @@ -11329,10 +10649,6 @@ packages: media-tracks@0.3.3: resolution: {integrity: sha512-9P2FuUHnZZ3iji+2RQk7Zkh5AmZTnOG5fODACnjhCVveX1McY3jmCRHofIEI+yTBqplz7LXy48c7fQ3Uigp88w==} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -11348,9 +10664,6 @@ packages: resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} engines: {node: '>=12.13'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -11366,10 +10679,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micro-api-client@3.3.0: resolution: {integrity: sha512-y0y6CUB9RLVsy3kfgayU28746QrNMpSm9O/AYGNsBgOkJr/X/Jk0VLGoO8Ude7Bpa8adywzF+MzXNZRFRsNPhg==} @@ -11485,11 +10794,6 @@ packages: engines: {node: '>=4'} hasBin: true - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -11616,10 +10920,6 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} - morgan@1.10.0: - resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} - engines: {node: '>= 0.8.0'} - motion-dom@11.18.1: resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} @@ -11834,9 +11134,6 @@ packages: engines: {node: ^16.14.0 || >=18.0.0} hasBin: true - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-mock-http@1.0.0: resolution: {integrity: sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==} @@ -11854,11 +11151,6 @@ packages: resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} engines: {node: '>=6.0.0'} - nodemon@3.1.10: - resolution: {integrity: sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==} - engines: {node: '>=10'} - hasBin: true - nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -12013,18 +11305,10 @@ packages: resolution: {integrity: sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g==} engines: {node: ^10.13.0 || >=12.0.0} - on-finished@2.3.0: - resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} - engines: {node: '>= 0.8'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.0.2: - resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} - engines: {node: '>= 0.8'} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -12084,10 +11368,6 @@ packages: resolution: {integrity: sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -12096,10 +11376,6 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -12124,10 +11400,6 @@ packages: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - p-wait-for@5.0.2: resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==} engines: {node: '>=12'} @@ -12168,10 +11440,6 @@ packages: resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} engines: {node: '>=14'} - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - parse-json@8.3.0: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} @@ -12220,9 +11488,6 @@ packages: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -12287,10 +11552,6 @@ packages: resolution: {integrity: sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==} engines: {node: '>=16.20.0'} - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - pkg-dir@7.0.0: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} @@ -12444,10 +11705,6 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} @@ -12519,9 +11776,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pstree.remy@1.1.8: - resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} - pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -12542,10 +11796,6 @@ packages: resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} engines: {node: '>=6.0.0'} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -12581,10 +11831,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - raw-body@3.0.1: resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} engines: {node: '>= 0.10'} @@ -12659,9 +11905,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-loading-skeleton@3.5.0: resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} peerDependencies: @@ -12893,10 +12136,6 @@ packages: resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -12908,10 +12147,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} @@ -12965,8 +12200,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.0.0-beta.43: - resolution: {integrity: sha512-6RcqyRx0tY1MlRLnjXPp/849Rl/CPFhzpGGwNPEPjKwqBMqPq/Rbbkxasa8s0x+IkUk46ty4jazb5skZ/Vgdhw==} + rolldown@1.0.0-beta.45: + resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -13192,10 +12427,6 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} - simple-update-notifier@2.0.0: - resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} - engines: {node: '>=10'} - sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -13298,9 +12529,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} - source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -13400,10 +12628,6 @@ packages: stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -13472,10 +12696,6 @@ packages: streamx@2.22.0: resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==} - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -13536,10 +12756,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -13602,16 +12818,6 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true - superagent@8.1.2: - resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} - engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net - - supertest@6.3.4: - resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} - engines: {node: '>=6.4.0'} - deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net - supports-color@10.0.0: resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==} engines: {node: '>=18'} @@ -13719,10 +12925,6 @@ packages: engines: {node: '>=10'} hasBin: true - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} @@ -13802,9 +13004,6 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -13820,10 +13019,6 @@ packages: toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} - touch@3.1.1: - resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} - hasBin: true - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -13875,30 +13070,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-jest@29.3.2: - resolution: {integrity: sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 - '@jest/types': ^29.0.0 - babel-jest: ^29.0.0 - esbuild: '*' - jest: ^29.0.0 - typescript: '>=4.3 <6' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -14043,26 +13214,14 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -14107,9 +13266,6 @@ packages: unctx@2.4.1: resolution: {integrity: sha512-AbaYw0Nm4mK4qjhns67C+kgxR2YWiwlDBPzxrN8h8C6VtAdCgditAY5Dezu3IJy4XVqAnbrXt9oQJvsn3fyozg==} - undefsafe@2.0.5: - resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} - undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -14427,10 +13583,6 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -14453,10 +13605,6 @@ packages: v8-compile-cache@2.4.0: resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==} - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - valibot@1.0.0-rc.1: resolution: {integrity: sha512-bTHNpeeQ403xS7qGHF/tw3EC/zkZOU5VdkfIsmRDu1Sp+BJNTNCm6m5HlwOgyW/03lofP+uQiq3R+Poo9wiCEg==} peerDependencies: @@ -14674,9 +13822,6 @@ packages: walk-up-path@3.0.1: resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - watchpack@2.4.4: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} @@ -14759,10 +13904,6 @@ packages: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -14842,10 +13983,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - write-file-atomic@5.0.1: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -16164,86 +15301,11 @@ snapshots: dependencies: '@babel/types': 7.28.4 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 @@ -16337,8 +15399,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@bcoe/v8-coverage@0.2.3': {} - '@biomejs/biome@2.2.0': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.2.0 @@ -16769,12 +15829,6 @@ snapshots: '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) effect: 3.18.4 - '@emnapi/core@1.4.3': - dependencies: - '@emnapi/wasi-threads': 1.0.2 - tslib: 2.8.1 - optional: true - '@emnapi/core@1.5.0': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -16791,11 +15845,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.0.2': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 @@ -17668,207 +16717,35 @@ snapshots: '@isaacs/string-locale-compare@1.1.0': {} - '@istanbuljs/load-nyc-config@1.1.0': + '@jridgewell/gen-mapping@0.3.13': dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.1 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.3': {} + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - '@jest/console@29.7.0': + '@jridgewell/gen-mapping@0.3.8': dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.31 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))': + '@jridgewell/remapping@2.3.5': dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@jest/environment@29.7.0': - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - jest-mock: 29.7.0 + '@jridgewell/resolve-uri@3.1.2': {} - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 + '@jridgewell/set-array@1.2.1': {} - '@jest/expect@29.7.0': + '@jridgewell/source-map@0.3.11': dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@jest/fake-timers@29.7.0': + '@jridgewell/source-map@0.3.6': dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.17.43 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - '@jest/globals@29.7.0': - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/reporters@29.7.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.17.43 - chalk: 4.1.2 - collect-v8-coverage: 1.0.2 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.1.7 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 - slash: 3.0.0 - string-length: 4.0.2 - strip-ansi: 6.0.1 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - - '@jest/source-map@29.6.3': - dependencies: - '@jridgewell/trace-mapping': 0.3.30 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.2 - - '@jest/test-sequencer@29.7.0': - dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 - - '@jest/transform@29.7.0': - dependencies: - '@babel/core': 7.27.1 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.25 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 20.17.43 - '@types/yargs': 17.0.33 - chalk: 4.1.2 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/gen-mapping@0.3.8': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/set-array@1.2.1': {} - - '@jridgewell/source-map@0.3.11': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/source-map@0.3.6': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/sourcemap-codec@1.5.0': {} @@ -18120,8 +16997,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.9': dependencies: - '@emnapi/core': 1.4.3 - '@emnapi/runtime': 1.4.3 + '@emnapi/core': 1.5.0 + '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.9.0 optional: true @@ -18868,6 +17745,8 @@ snapshots: '@oxc-project/types@0.94.0': {} + '@oxc-project/types@0.95.0': {} + '@panva/hkdf@1.2.1': {} '@paralleldrive/cuid2@2.2.2': @@ -19827,61 +18706,61 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-android-arm64@1.0.0-beta.43': + '@rolldown/binding-android-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-darwin-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.43': + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-darwin-x64@1.0.0-beta.42': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.43': + '@rolldown/binding-darwin-x64@1.0.0-beta.45': optional: true '@rolldown/binding-freebsd-x64@1.0.0-beta.42': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.43': + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.43': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.43': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.43': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.43': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.43': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.43': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-wasm32-wasi@1.0.0-beta.42': @@ -19889,7 +18768,7 @@ snapshots: '@napi-rs/wasm-runtime': 1.0.6 optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.43': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true @@ -19897,24 +18776,24 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': optional: true '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': optional: true '@rolldown/pluginutils@1.0.0-beta.42': {} - '@rolldown/pluginutils@1.0.0-beta.43': {} + '@rolldown/pluginutils@1.0.0-beta.45': {} '@rollup/plugin-alias@5.1.1(rollup@4.40.2)': optionalDependencies: @@ -20115,22 +18994,12 @@ snapshots: '@sigstore/core': 1.1.0 '@sigstore/protobuf-specs': 0.3.3 - '@sinclair/typebox@0.27.8': {} - '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@7.0.1': {} '@sindresorhus/merge-streams@2.3.0': {} - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 - '@smithy/abort-controller@4.0.2': dependencies: '@smithy/types': 4.3.1 @@ -20903,11 +19772,11 @@ snapshots: dependencies: solid-js: 1.9.6 - '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': + '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': dependencies: '@tanstack/server-functions-plugin': 1.119.2(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) defu: 6.1.4 error-stack-parser: 2.1.4 html-to-image: 1.11.13 @@ -20918,7 +19787,7 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.0.6(solid-js@1.9.6) tinyglobby: 0.2.13 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-solid: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -21046,9 +19915,9 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - '@storybook/builder-vite@10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/builder-vite@10.1.0-alpha.3(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: - '@storybook/csf-plugin': 10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/csf-plugin': 10.1.0-alpha.3(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) @@ -21078,7 +19947,7 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/csf-plugin@10.1.0-alpha.3(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: storybook: 8.6.12(prettier@3.5.3) unplugin: 2.3.10 @@ -21547,13 +20416,17 @@ snapshots: '@tsconfig/bun@1.0.7': {} - '@tsconfig/node10@1.0.11': {} + '@tsconfig/node10@1.0.11': + optional: true - '@tsconfig/node12@1.0.11': {} + '@tsconfig/node12@1.0.11': + optional: true - '@tsconfig/node14@1.0.3': {} + '@tsconfig/node14@1.0.3': + optional: true - '@tsconfig/node16@1.0.4': {} + '@tsconfig/node16@1.0.4': + optional: true '@tufjs/canonical-json@2.0.0': {} @@ -21627,8 +20500,6 @@ snapshots: '@types/cookie@0.6.0': {} - '@types/cookiejar@2.1.5': {} - '@types/cookies@0.9.0': dependencies: '@types/connect': 3.4.38 @@ -21636,10 +20507,6 @@ snapshots: '@types/keygrip': 1.0.6 '@types/node': 20.17.43 - '@types/cors@2.8.17': - dependencies: - '@types/node': 20.17.43 - '@types/cors@2.8.19': dependencies: '@types/node': 20.17.43 @@ -21684,16 +20551,8 @@ snapshots: '@types/file-saver@2.0.7': {} - '@types/fluent-ffmpeg@2.1.27': - dependencies: - '@types/node': 20.17.43 - '@types/google-protobuf@3.15.12': {} - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 20.17.43 - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -21704,21 +20563,6 @@ snapshots: '@types/http-errors@2.0.4': {} - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest@29.5.14': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 - '@types/js-cookie@3.0.6': {} '@types/jsdom@21.1.7': @@ -21760,18 +20604,12 @@ snapshots: '@types/mdx@2.0.13': {} - '@types/methods@1.1.4': {} - '@types/micromatch@4.0.9': dependencies: '@types/braces': 3.0.5 '@types/mime@1.3.5': {} - '@types/morgan@1.9.9': - dependencies: - '@types/node': 20.17.43 - '@types/ms@2.1.0': {} '@types/node-fetch@2.6.12': @@ -21861,20 +20699,6 @@ snapshots: '@types/shimmer@1.2.0': {} - '@types/stack-utils@2.0.3': {} - - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 20.17.43 - form-data: 4.0.2 - - '@types/supertest@6.0.3': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 - '@types/tmp@0.2.6': {} '@types/tough-cookie@4.0.5': {} @@ -21890,12 +20714,6 @@ snapshots: '@types/uuid@9.0.8': {} - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.33': - dependencies: - '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': dependencies: '@types/node': 20.19.19 @@ -21908,7 +20726,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 @@ -21920,24 +20738,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': - dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 - natural-compare: 1.4.0 - ts-api-utils: 1.4.3(typescript@5.8.3) - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -21961,20 +20761,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) - debug: 4.4.0(supports-color@5.5.0) - eslint: 8.57.1 - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 eslint: 8.57.1 optionalDependencies: typescript: 5.8.3 @@ -21987,7 +20774,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 eslint: 9.30.1(jiti@2.6.1) optionalDependencies: typescript: 5.8.3 @@ -22016,18 +20803,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.1 - eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@5.8.3) - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) @@ -22088,17 +20863,6 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.8.3)': - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) - eslint: 8.57.1 - transitivePeerDependencies: - - supports-color - - typescript - '@typescript-eslint/utils@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.30.1(jiti@2.6.1)) @@ -22261,7 +21025,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: '@babel/parser': 7.27.2 acorn: 8.14.1 @@ -22272,18 +21036,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) - '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) acorn: 8.14.1 acorn-loose: 8.5.0 acorn-typescript: 1.4.13(acorn@8.14.1) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) '@virtual-grid/core@2.0.1': {} @@ -22546,6 +21310,7 @@ snapshots: acorn-walk@8.3.4: dependencies: acorn: 8.15.0 + optional: true acorn@7.4.1: {} @@ -22601,10 +21366,6 @@ snapshots: ansi-colors@4.1.3: {} - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -22695,7 +21456,8 @@ snapshots: delegates: 1.0.0 readable-stream: 3.6.2 - arg@4.1.3: {} + arg@4.1.3: + optional: true arg@5.0.2: {} @@ -22724,8 +21486,6 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-flatten@1.1.1: {} - array-includes@3.1.8: dependencies: call-bind: 1.0.8 @@ -22794,8 +21554,6 @@ snapshots: dependencies: printable-characters: 1.0.42 - asap@2.0.6: {} - asn1js@3.0.6: dependencies: pvtsutils: 1.3.6 @@ -22829,8 +21587,6 @@ snapshots: async-sema@3.1.1: {} - async@0.2.10: {} - async@3.2.6: {} asynckit@0.4.0: {} @@ -22889,42 +21645,12 @@ snapshots: transitivePeerDependencies: - supports-color - babel-jest@29.7.0(@babel/core@7.27.1): - dependencies: - '@babel/core': 7.27.1 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.27.1) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - babel-loader@10.0.0(@babel/core@7.27.1)(webpack@5.101.3(esbuild@0.25.5)): dependencies: '@babel/core': 7.27.1 find-up: 5.0.0 webpack: 5.101.3(esbuild@0.25.5) - babel-plugin-istanbul@6.1.1: - dependencies: - '@babel/helper-plugin-utils': 7.27.1 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-jest-hoist@29.6.3: - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.20.7 - babel-plugin-jsx-dom-expressions@0.39.8(@babel/core@7.27.1): dependencies: '@babel/core': 7.27.1 @@ -22935,31 +21661,6 @@ snapshots: parse5: 7.3.0 validate-html-nesting: 1.2.2 - babel-preset-current-node-syntax@1.1.0(@babel/core@7.27.1): - dependencies: - '@babel/core': 7.27.1 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.27.1) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.27.1) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.27.1) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.27.1) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.27.1) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.27.1) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.27.1) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.1) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.27.1) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.27.1) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.27.1) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.1) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.27.1) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.27.1) - - babel-preset-jest@29.6.3(@babel/core@7.27.1): - dependencies: - '@babel/core': 7.27.1 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.1) - babel-preset-solid@1.9.6(@babel/core@7.27.1): dependencies: '@babel/core': 7.27.1 @@ -22978,10 +21679,6 @@ snapshots: baseline-browser-mapping@2.8.16: {} - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 - before-after-hook@2.2.3: {} before-after-hook@3.0.2: {} @@ -23017,23 +21714,6 @@ snapshots: blake3-wasm@2.1.5: {} - body-parser@1.20.3: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -23093,14 +21773,6 @@ snapshots: node-releases: 2.0.23 update-browserslist-db: 1.1.3(browserslist@4.26.3) - bs-logger@0.2.6: - dependencies: - fast-json-stable-stringify: 2.1.0 - - bser@2.1.1: - dependencies: - node-int64: 0.4.0 - buffer-crc32@0.2.13: {} buffer-crc32@1.0.0: {} @@ -23206,10 +21878,6 @@ snapshots: camelcase-css@2.0.1: {} - camelcase@5.3.1: {} - - camelcase@6.3.0: {} - camelcase@8.0.0: {} caniuse-lite@1.0.30001717: {} @@ -23260,8 +21928,6 @@ snapshots: chalk@5.4.1: {} - char-regex@1.0.2: {} - character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -23296,8 +21962,6 @@ snapshots: chrome-trace-event@1.0.4: {} - ci-info@3.9.0: {} - citty@0.1.6: dependencies: consola: 3.4.2 @@ -23358,12 +22022,8 @@ snapshots: transitivePeerDependencies: - '@types/react' - co@4.6.0: {} - collapse-white-space@2.1.0: {} - collect-v8-coverage@1.0.2: {} - color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -23429,8 +22089,6 @@ snapshots: compatx@0.2.0: {} - component-emitter@1.3.1: {} - compress-commons@4.1.2: dependencies: buffer-crc32: 0.2.13 @@ -23461,16 +22119,10 @@ snapshots: confbox@0.2.2: {} - confusing-browser-globals@1.0.11: {} - consola@3.4.2: {} console-control-strings@1.1.0: {} - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -23498,20 +22150,14 @@ snapshots: cookie-es@2.0.0: {} - cookie-signature@1.0.6: {} - cookie-signature@1.2.2: {} cookie@0.5.0: {} - cookie@0.7.1: {} - cookie@0.7.2: {} cookie@1.0.2: {} - cookiejar@2.1.4: {} - cookies-next@4.3.0: dependencies: '@types/cookie': 0.6.0 @@ -23544,22 +22190,8 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 - create-jest@29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - create-require@1.1.1: {} + create-require@1.1.1: + optional: true cron-parser@4.9.0: dependencies: @@ -23657,11 +22289,9 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.0(supports-color@5.5.0): + debug@4.4.0: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 5.5.0 debug@4.4.1: dependencies: @@ -23761,8 +22391,6 @@ snapshots: detect-libc@2.1.2: optional: true - detect-newline@3.1.0: {} - detect-node-es@1.1.0: {} detective-amd@5.0.2: @@ -23814,16 +22442,10 @@ snapshots: dependencies: dequal: 2.0.3 - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - didyoumean@1.2.2: {} - diff-sequences@29.6.3: {} - - diff@4.0.2: {} + diff@4.0.2: + optional: true diff@7.0.0: {} @@ -23931,16 +22553,10 @@ snapshots: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 - ejs@3.1.10: - dependencies: - jake: 10.9.2 - electron-to-chromium@1.5.150: {} electron-to-chromium@1.5.234: {} - emittery@0.13.1: {} - emoji-regex-xs@1.0.0: {} emoji-regex@10.4.0: {} @@ -24004,10 +22620,6 @@ snapshots: err-code@2.0.3: {} - error-ex@1.3.2: - dependencies: - is-arrayish: 0.2.1 - error-stack-parser-es@1.0.5: {} error-stack-parser@2.1.4: @@ -24320,8 +22932,6 @@ snapshots: escape-string-regexp@1.0.5: {} - escape-string-regexp@2.0.0: {} - escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -24334,24 +22944,6 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0)(eslint@8.57.1): - dependencies: - confusing-browser-globals: 1.0.11 - eslint: 8.57.1 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) - object.assign: 4.1.7 - object.entries: 1.1.9 - semver: 6.3.1 - - eslint-config-airbnb-typescript@18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-plugin-import@2.31.0)(eslint@8.57.1): - dependencies: - '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) - eslint: 8.57.1 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0)(eslint@8.57.1) - transitivePeerDependencies: - - eslint-plugin-import - eslint-config-next@13.3.0(eslint@8.57.1)(typescript@5.8.3): dependencies: '@next/eslint-plugin-next': 13.3.0 @@ -24411,7 +23003,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 eslint: 8.57.1 get-tsconfig: 4.10.0 is-bun-module: 2.0.0 @@ -24419,14 +23011,14 @@ snapshots: tinyglobby: 0.2.13 unrs-resolver: 1.7.2 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.30.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 eslint: 9.30.1(jiti@2.6.1) get-tsconfig: 4.10.0 is-bun-module: 2.0.0 @@ -24449,17 +23041,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -24500,35 +23081,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -24707,7 +23259,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 doctrine: 3.0.0 enquirer: 2.4.1 escape-string-regexp: 4.0.0 @@ -24960,60 +23512,14 @@ snapshots: exit-hook@2.2.1: {} - exit@0.1.2: {} - expect-type@1.2.1: {} - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - exponential-backoff@3.1.2: {} express-rate-limit@7.5.1(express@5.1.0): dependencies: express: 5.1.0 - express@4.21.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.3 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.1 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.13.0 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - express@5.1.0: dependencies: accepts: 2.0.0 @@ -25098,8 +23604,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-safe-stringify@2.1.1: {} - fast-uri@3.0.6: {} fast-xml-parser@4.4.1: @@ -25118,10 +23622,6 @@ snapshots: dependencies: format: 0.2.2 - fb-watchman@2.0.2: - dependencies: - bser: 2.1.1 - fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -25163,10 +23663,6 @@ snapshots: file-uri-to-path@1.0.0: {} - filelist@1.0.4: - dependencies: - minimatch: 5.1.6 - filesize@10.1.6: {} fill-range@7.1.1: @@ -25175,18 +23671,6 @@ snapshots: filter-obj@5.1.0: {} - finalhandler@1.3.1: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - finalhandler@2.1.0: dependencies: debug: 4.4.3 @@ -25202,11 +23686,6 @@ snapshots: find-up-simple@1.0.1: {} - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -25242,11 +23721,6 @@ snapshots: flatted@3.3.3: {} - fluent-ffmpeg@2.1.3: - dependencies: - async: 0.2.10 - which: 1.3.1 - fn.name@1.1.0: {} follow-redirects@1.15.9: {} @@ -25280,13 +23754,6 @@ snapshots: dependencies: fetch-blob: 3.2.0 - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.2.2 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.14.0 - forwarded@0.2.0: {} fraction.js@4.3.7: {} @@ -25389,8 +23856,6 @@ snapshots: get-nonce@1.0.1: {} - get-package-type@0.1.0: {} - get-port-please@3.1.2: {} get-proto@1.0.1: @@ -25691,8 +24156,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - helmet@7.2.0: {} - hls.js@0.14.17: dependencies: eventemitter3: 4.0.7 @@ -25718,8 +24181,6 @@ snapshots: html-entities@2.3.3: {} - html-escaper@2.0.2: {} - html-to-image@1.11.13: {} html-to-text@9.0.5: @@ -25799,10 +24260,6 @@ snapshots: hyperdyperid@1.2.0: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -25815,8 +24272,6 @@ snapshots: ieee754@1.2.1: {} - ignore-by-default@1.0.1: {} - ignore-walk@6.0.5: dependencies: minimatch: 9.0.5 @@ -25843,11 +24298,6 @@ snapshots: cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -25935,8 +24385,6 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 - is-arrayish@0.2.1: {} - is-arrayish@0.3.2: {} is-async-function@2.1.1: @@ -26001,8 +24449,6 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-generator-fn@2.1.0: {} - is-generator-function@1.1.0: dependencies: call-bound: 1.0.4 @@ -26128,47 +24574,6 @@ snapshots: isexe@3.1.1: {} - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.27.1 - '@babel/parser': 7.28.4 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.27.1 - '@babel/parser': 7.27.5 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@4.0.1: - dependencies: - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.1.7: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -26188,328 +24593,12 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 - jake@10.9.2: - dependencies: - async: 3.2.6 - chalk: 4.1.2 - filelist: 1.0.4 - minimatch: 3.1.2 - - jest-changed-files@29.7.0: - dependencies: - execa: 5.1.1 - jest-util: 29.7.0 - p-limit: 3.1.0 - - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.6.0 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-config@29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.27.1 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.27.1) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.17.43 - ts-node: 10.9.2(@types/node@20.17.43)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-docblock@29.7.0: - dependencies: - detect-newline: 3.1.0 - - jest-each@29.7.0: - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - jest-get-type: 29.6.3 - jest-util: 29.7.0 - pretty-format: 29.7.0 - - jest-environment-node@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - jest-get-type@29.6.3: {} - - jest-haste-map@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 20.17.43 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - - jest-leak-detector@29.7.0: - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - jest-util: 29.7.0 - - jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - optionalDependencies: - jest-resolve: 29.7.0 - - jest-regex-util@29.6.3: {} - - jest-resolve-dependencies@29.7.0: - dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - jest-resolve@29.7.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - resolve: 1.22.10 - resolve.exports: 2.0.3 - slash: 3.0.0 - - jest-runner@29.7.0: - dependencies: - '@jest/console': 29.7.0 - '@jest/environment': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - chalk: 4.1.2 - emittery: 0.13.1 - graceful-fs: 4.2.11 - jest-docblock: 29.7.0 - jest-environment-node: 29.7.0 - jest-haste-map: 29.7.0 - jest-leak-detector: 29.7.0 - jest-message-util: 29.7.0 - jest-resolve: 29.7.0 - jest-runtime: 29.7.0 - jest-util: 29.7.0 - jest-watcher: 29.7.0 - jest-worker: 29.7.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color - - jest-runtime@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 - '@jest/source-map': 29.6.3 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color - - jest-snapshot@29.7.0: - dependencies: - '@babel/core': 7.27.1 - '@babel/generator': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.1) - '@babel/types': 7.27.1 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.1) - chalk: 4.1.2 - expect: 29.7.0 - graceful-fs: 4.2.11 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - natural-compare: 1.4.0 - pretty-format: 29.7.0 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - - jest-validate@29.7.0: - dependencies: - '@jest/types': 29.6.3 - camelcase: 6.3.0 - chalk: 4.1.2 - jest-get-type: 29.6.3 - leven: 3.1.0 - pretty-format: 29.7.0 - - jest-watcher@29.7.0: - dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.17.43 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 - jest-worker@27.5.1: dependencies: '@types/node': 20.19.21 merge-stream: 2.0.0 supports-color: 8.1.1 - jest-worker@29.7.0: - dependencies: - '@types/node': 20.17.43 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest@29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jiti@1.21.7: {} jiti@2.4.2: {} @@ -26666,8 +24755,6 @@ snapshots: leb@1.0.0: {} - leven@3.1.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -26717,10 +24804,6 @@ snapshots: pkg-types: 2.1.0 quansync: 0.2.10 - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -26747,8 +24830,6 @@ snapshots: lodash.isplainobject@4.0.6: {} - lodash.memoize@4.1.2: {} - lodash.merge@4.6.2: {} lodash.sortby@4.7.0: {} @@ -26850,11 +24931,8 @@ snapshots: dependencies: semver: 6.3.1 - make-dir@4.0.0: - dependencies: - semver: 7.7.2 - - make-error@1.3.6: {} + make-error@1.3.6: + optional: true make-fetch-happen@13.0.1: dependencies: @@ -26873,10 +24951,6 @@ snapshots: transitivePeerDependencies: - supports-color - makeerror@1.0.12: - dependencies: - tmpl: 1.0.5 - map-or-similar@1.5.0: {} markdown-extensions@2.0.0: {} @@ -27031,8 +25105,6 @@ snapshots: media-tracks@0.3.3: {} - media-typer@0.3.0: {} - media-typer@1.1.0: {} memfs@4.17.1: @@ -27050,8 +25122,6 @@ snapshots: dependencies: is-what: 4.1.16 - merge-descriptors@1.0.3: {} - merge-descriptors@2.0.0: {} merge-options@3.0.4: @@ -27062,8 +25132,6 @@ snapshots: merge2@1.4.1: {} - methods@1.1.2: {} - micro-api-client@3.3.0: {} micromark-core-commonmark@2.0.3: @@ -27298,8 +25366,6 @@ snapshots: mime@1.6.0: {} - mime@2.6.0: {} - mime@3.0.0: {} mime@4.0.7: {} @@ -27429,16 +25495,6 @@ snapshots: moment@2.30.1: {} - morgan@1.10.0: - dependencies: - basic-auth: 2.0.1 - debug: 2.6.9 - depd: 2.0.0 - on-finished: 2.3.0 - on-headers: 1.0.2 - transitivePeerDependencies: - - supports-color - motion-dom@11.18.1: dependencies: motion-utils: 11.18.1 @@ -27538,7 +25594,7 @@ snapshots: p-wait-for: 5.0.2 qs: 6.14.0 - next-auth@4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next-auth@4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@babel/runtime': 7.27.1 '@panva/hkdf': 1.2.1 @@ -27613,7 +25669,7 @@ snapshots: cors: 2.8.5 next: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - nitropack@2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(xml2js@0.6.2): + nitropack@2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@netlify/functions': 3.1.5(encoding@0.1.13)(rollup@4.40.2) @@ -27667,7 +25723,7 @@ snapshots: pretty-bytes: 6.1.1 radix3: 1.1.2 rollup: 4.40.2 - rollup-plugin-visualizer: 5.14.0(rolldown@1.0.0-beta.43)(rollup@4.40.2) + rollup-plugin-visualizer: 5.14.0(rollup@4.40.2) scule: 1.3.0 semver: 7.7.2 serve-placeholder: 2.0.2 @@ -27762,8 +25818,6 @@ snapshots: transitivePeerDependencies: - supports-color - node-int64@0.4.0: {} - node-mock-http@1.0.0: {} node-releases@2.0.19: {} @@ -27776,19 +25830,6 @@ snapshots: nodemailer@6.10.1: {} - nodemon@3.1.10: - dependencies: - chokidar: 3.6.0 - debug: 4.4.0(supports-color@5.5.0) - ignore-by-default: 1.0.1 - minimatch: 3.1.2 - pstree.remy: 1.1.8 - semver: 7.7.1 - simple-update-notifier: 2.0.0 - supports-color: 5.5.0 - touch: 3.1.1 - undefsafe: 2.0.5 - nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -27967,16 +26008,10 @@ snapshots: oidc-token-hash@5.1.1: {} - on-finished@2.3.0: - dependencies: - ee-first: 1.1.1 - on-finished@2.4.1: dependencies: ee-first: 1.1.1 - on-headers@1.0.2: {} - once@1.4.0: dependencies: wrappy: 1.0.2 @@ -28068,10 +26103,6 @@ snapshots: dependencies: p-timeout: 5.1.0 - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -28080,10 +26111,6 @@ snapshots: dependencies: yocto-queue: 1.2.1 - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -28102,8 +26129,6 @@ snapshots: p-timeout@6.1.4: {} - p-try@2.2.0: {} - p-wait-for@5.0.2: dependencies: p-timeout: 6.1.4 @@ -28167,13 +26192,6 @@ snapshots: parse-gitignore@2.0.0: {} - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.2 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - parse-json@8.3.0: dependencies: '@babel/code-frame': 7.27.1 @@ -28218,8 +26236,6 @@ snapshots: lru-cache: 11.1.0 minipass: 7.1.2 - path-to-regexp@0.1.12: {} - path-to-regexp@6.3.0: {} path-to-regexp@8.3.0: {} @@ -28256,10 +26272,6 @@ snapshots: pkce-challenge@4.1.0: {} - pkg-dir@4.2.0: - dependencies: - find-up: 4.1.0 - pkg-dir@7.0.0: dependencies: find-up: 6.3.0 @@ -28419,12 +26431,6 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - pretty-format@3.8.0: {} printable-characters@1.0.42: {} @@ -28489,8 +26495,6 @@ snapshots: proxy-from-env@1.1.0: {} - pstree.remy@1.1.8: {} - pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -28508,10 +26512,6 @@ snapshots: pvutils@1.1.3: {} - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -28536,13 +26536,6 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - raw-body@3.0.1: dependencies: bytes: 3.1.2 @@ -28656,8 +26649,6 @@ snapshots: react-is@17.0.2: {} - react-is@18.3.1: {} - react-loading-skeleton@3.5.0(react@19.1.1): dependencies: react: 19.1.1 @@ -28967,18 +26958,12 @@ snapshots: resolve-alpn@1.2.1: {} - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} - resolve.exports@2.0.3: {} - resolve@1.22.10: dependencies: is-core-module: 2.16.1 @@ -29008,7 +26993,7 @@ snapshots: dependencies: glob: 7.2.3 - rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.43)(typescript@5.8.3): + rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.45)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.4 @@ -29019,7 +27004,7 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.11.0 magic-string: 0.30.19 - rolldown: 1.0.0-beta.43 + rolldown: 1.0.0-beta.45 optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -29047,26 +27032,25 @@ snapshots: '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.42 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.42 - rolldown@1.0.0-beta.43: + rolldown@1.0.0-beta.45: dependencies: - '@oxc-project/types': 0.94.0 - '@rolldown/pluginutils': 1.0.0-beta.43 - ansis: 4.2.0 + '@oxc-project/types': 0.95.0 + '@rolldown/pluginutils': 1.0.0-beta.45 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.43 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.43 - '@rolldown/binding-darwin-x64': 1.0.0-beta.43 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.43 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.43 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.43 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.43 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.43 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.43 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.43 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.43 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.43 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.43 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.43 + '@rolldown/binding-android-arm64': 1.0.0-beta.45 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.45 + '@rolldown/binding-darwin-x64': 1.0.0-beta.45 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.45 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.45 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.45 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.45 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.45 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.45 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.45 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.45 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.45 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.45 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.45 rollup-plugin-inject@3.0.2: dependencies: @@ -29078,14 +27062,13 @@ snapshots: dependencies: rollup-plugin-inject: 3.0.2 - rollup-plugin-visualizer@5.14.0(rolldown@1.0.0-beta.43)(rollup@4.40.2): + rollup-plugin-visualizer@5.14.0(rollup@4.40.2): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rolldown: 1.0.0-beta.43 rollup: 4.40.2 rollup-pluginutils@2.8.2: @@ -29422,10 +27405,6 @@ snapshots: dependencies: is-arrayish: 0.3.2 - simple-update-notifier@2.0.0: - dependencies: - semver: 7.7.1 - sisteransi@1.0.5: {} slash@3.0.0: {} @@ -29556,11 +27535,6 @@ snapshots: source-map-js@1.2.1: {} - source-map-support@0.5.13: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -29649,10 +27623,6 @@ snapshots: stack-trace@0.0.10: {} - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} stackframe@1.3.4: {} @@ -29681,7 +27651,7 @@ snapshots: storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.5.3))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)): dependencies: - '@storybook/builder-vite': 10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/builder-vite': 10.1.0-alpha.3(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) '@storybook/types': 9.0.0-alpha.1(storybook@8.6.12(prettier@3.5.3)) magic-string: 0.30.17 solid-js: 1.9.6 @@ -29726,11 +27696,6 @@ snapshots: optionalDependencies: bare-events: 2.5.4 - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -29824,8 +27789,6 @@ snapshots: strip-bom@3.0.0: {} - strip-bom@4.0.0: {} - strip-final-newline@2.0.0: {} strip-final-newline@3.0.0: {} @@ -29884,28 +27847,6 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.1 - fast-safe-stringify: 2.1.1 - form-data: 4.0.2 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.14.0 - semver: 7.7.1 - transitivePeerDependencies: - - supports-color - - supertest@6.3.4: - dependencies: - methods: 1.1.2 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - supports-color@10.0.0: {} supports-color@5.5.0: @@ -30078,12 +28019,6 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 - text-decoder@1.2.3: dependencies: b4a: 1.6.7 @@ -30148,8 +28083,6 @@ snapshots: tmp@0.2.5: {} - tmpl@1.0.5: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -30160,8 +28093,6 @@ snapshots: toml@3.0.0: {} - touch@3.1.1: {} - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -30198,26 +28129,6 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.3.2(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(jest@29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)))(typescript@5.8.3): - dependencies: - bs-logger: 0.2.6 - ejs: 3.1.10 - fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.17.43)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) - jest-util: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.1 - type-fest: 4.41.0 - typescript: 5.8.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.27.1 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.27.1) - ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -30235,6 +28146,7 @@ snapshots: typescript: 5.8.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optional: true ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3): dependencies: @@ -30285,8 +28197,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.43 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.43)(typescript@5.8.3) + rolldown: 1.0.0-beta.45 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.45)(typescript@5.8.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -30385,19 +28297,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.0.8: {} - type-fest@0.20.2: {} - type-fest@0.21.3: {} - type-fest@4.41.0: {} - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -30466,8 +28369,6 @@ snapshots: magic-string: 0.30.17 unplugin: 2.3.2 - undefsafe@2.0.5: {} - undici-types@5.26.5: {} undici-types@5.28.4: {} @@ -30662,7 +28563,7 @@ snapshots: '@antfu/install-pkg': 0.4.1 '@antfu/utils': 0.7.10 '@iconify/utils': 2.3.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 kolorist: 1.8.0 local-pkg: 0.5.1 unplugin: 1.16.1 @@ -30828,8 +28729,6 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.19 - utils-merge@1.0.1: {} - uuid@11.1.0: {} uuid@8.0.0: {} @@ -30838,16 +28737,11 @@ snapshots: uuid@9.0.1: {} - v8-compile-cache-lib@3.0.1: {} + v8-compile-cache-lib@3.0.1: + optional: true v8-compile-cache@2.4.0: {} - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.30 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - valibot@1.0.0-rc.1(typescript@5.8.3): optionalDependencies: typescript: 5.8.3 @@ -30878,7 +28772,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): + vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@babel/core': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) @@ -30900,7 +28794,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(xml2js@0.6.2) + nitropack: 2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(xml2js@0.6.2) node-fetch-native: 1.6.6 path-to-regexp: 6.3.0 pathe: 1.1.2 @@ -30991,7 +28885,7 @@ snapshots: vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.8.3) optionalDependencies: @@ -31002,7 +28896,7 @@ snapshots: vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.8.3) optionalDependencies: @@ -31077,7 +28971,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.2.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 expect-type: 1.2.1 magic-string: 0.30.17 pathe: 1.1.2 @@ -31109,10 +29003,6 @@ snapshots: walk-up-path@3.0.1: {} - walker@1.0.8: - dependencies: - makeerror: 1.0.12 - watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 @@ -31239,10 +29129,6 @@ snapshots: gopd: 1.2.0 has-tostringtag: 1.0.2 - which@1.3.1: - dependencies: - isexe: 2.0.0 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -31361,11 +29247,6 @@ snapshots: wrappy@1.0.2: {} - write-file-atomic@4.0.2: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - write-file-atomic@5.0.1: dependencies: imurmurhash: 0.1.4 @@ -31426,7 +29307,8 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - yn@3.1.1: {} + yn@3.1.1: + optional: true yocto-queue@0.1.0: {} From 6031aaf6566e97f9aa880815484971867d0f8847 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:36:05 +0000 Subject: [PATCH 02/56] feat: picture in picture camera --- .../WebRecorderDialog/CameraPreviewWindow.tsx | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx index a695943901..88de2a719e 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx @@ -28,6 +28,7 @@ export const CameraPreviewWindow = ({ const containerRef = useRef(null); const [videoDimensions, setVideoDimensions] = useState<{ width: number; height: number } | null>(null); const [mounted, setMounted] = useState(false); + const pipAutoEnteredRef = useRef(false); useEffect(() => { setMounted(true); @@ -157,6 +158,88 @@ export const CameraPreviewWindow = ({ } }, [isDragging, handleMouseMove, handleMouseUp]); + const handleClose = useCallback(async () => { + if (videoRef.current && document.pictureInPictureElement === videoRef.current) { + try { + await document.exitPictureInPicture(); + } catch (err) { + console.error("Failed to exit Picture-in-Picture", err); + } + } + onClose(); + }, [onClose]); + + useEffect(() => { + if (!videoRef.current || !videoDimensions) return; + + const video = videoRef.current; + + const enterPictureInPicture = async () => { + if (!video || !document.pictureInPictureEnabled) return; + + const isAlreadyInPip = document.pictureInPictureElement === video; + if (isAlreadyInPip) return; + + try { + await video.requestPictureInPicture(); + pipAutoEnteredRef.current = true; + } catch (err) { + console.error("Failed to enter Picture-in-Picture", err); + } + }; + + const exitPictureInPicture = async () => { + if (!video || document.pictureInPictureElement !== video) return; + + try { + await document.exitPictureInPicture(); + pipAutoEnteredRef.current = false; + } catch (err) { + console.error("Failed to exit Picture-in-Picture", err); + } + }; + + const handleVisibilityChange = () => { + if (document.hidden) { + enterPictureInPicture(); + } else if (pipAutoEnteredRef.current) { + exitPictureInPicture(); + } + }; + + const handleWindowBlur = () => { + enterPictureInPicture(); + }; + + const handleWindowFocus = () => { + if (pipAutoEnteredRef.current) { + exitPictureInPicture(); + } + }; + + const handlePipEnter = () => { + pipAutoEnteredRef.current = true; + }; + + const handlePipLeave = () => { + pipAutoEnteredRef.current = false; + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("blur", handleWindowBlur); + window.addEventListener("focus", handleWindowFocus); + video.addEventListener("enterpictureinpicture", handlePipEnter); + video.addEventListener("leavepictureinpicture", handlePipLeave); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("blur", handleWindowBlur); + window.removeEventListener("focus", handleWindowFocus); + video.removeEventListener("enterpictureinpicture", handlePipEnter); + video.removeEventListener("leavepictureinpicture", handlePipLeave); + }; + }, [videoDimensions]); + if (!mounted || !position) { return null; } @@ -214,7 +297,7 @@ export const CameraPreviewWindow = ({ type="button" onClick={(e) => { e.stopPropagation(); - onClose(); + handleClose(); }} className="p-2 rounded-lg ui-pressed:bg-gray-3 ui-pressed:text-gray-12 hover:bg-gray-3 hover:text-gray-12" > From 1c83f8f7c76d520402f61d93a2ef667d42399c5a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:08:10 +0000 Subject: [PATCH 03/56] feat: clear errors in web recorder files --- .../dashboard/_components/Navbar/Items.tsx | 819 +++++++++--------- .../WebRecorderDialog/CameraPreviewWindow.tsx | 356 ++++---- .../WebRecorderDialog/CameraSelector.tsx | 71 +- .../WebRecorderDialog/MicrophoneSelector.tsx | 71 +- .../WebRecorderDialog/WebRecorderDialog.tsx | 16 + 5 files changed, 715 insertions(+), 618 deletions(-) diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index a7ad288067..d75c508dff 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -1,28 +1,28 @@ "use client"; import { buildEnv } from "@cap/env"; import { - Avatar, - Button, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - Popover, - PopoverContent, - PopoverTrigger, + Avatar, + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Popover, + PopoverContent, + PopoverTrigger, } from "@cap/ui"; import { classNames } from "@cap/utils"; import { - faBuilding, - faCircleInfo, - faLink, + faBuilding, + faCircleInfo, + faLink, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; @@ -43,410 +43,411 @@ import SpacesList from "./SpacesList"; import { updateActiveOrganization } from "./server"; interface Props { - toggleMobileNav?: () => void; + toggleMobileNav?: () => void; } const AdminNavItems = ({ toggleMobileNav }: Props) => { - const pathname = usePathname(); - const [open, setOpen] = useState(false); - const { user, sidebarCollapsed, userCapsCount } = useDashboardContext(); + const pathname = usePathname(); + const [open, setOpen] = useState(false); + const { user, sidebarCollapsed, userCapsCount } = useDashboardContext(); - const manageNavigation = [ - { - name: "My Caps", - href: `/dashboard/caps`, - extraText: userCapsCount, - icon: , - subNav: [], - }, - { - name: "Organization Settings", - href: `/dashboard/settings/organization`, - ownerOnly: true, - icon: , - subNav: [], - }, - ]; + const manageNavigation = [ + { + name: "My Caps", + href: `/dashboard/caps`, + extraText: userCapsCount, + icon: , + subNav: [], + }, + { + name: "Organization Settings", + href: `/dashboard/settings/organization`, + ownerOnly: true, + icon: , + subNav: [], + }, + ]; - const [dialogOpen, setDialogOpen] = useState(false); - const { organizationData: orgData, activeOrganization: activeOrg } = - useDashboardContext(); - const formRef = useRef(null); - const [createLoading, setCreateLoading] = useState(false); - const [organizationName, setOrganizationName] = useState(""); - const isOwner = activeOrg?.organization.ownerId === user.id; - const [openAIDialog, setOpenAIDialog] = useState(false); - const router = useRouter(); + const [dialogOpen, setDialogOpen] = useState(false); + const { organizationData: orgData, activeOrganization: activeOrg } = + useDashboardContext(); + const formRef = useRef(null); + const [createLoading, setCreateLoading] = useState(false); + const [organizationName, setOrganizationName] = useState(""); + const isOwner = activeOrg?.organization.ownerId === user.id; + const [openAIDialog, setOpenAIDialog] = useState(false); + const router = useRouter(); - const isPathActive = (path: string) => pathname.includes(path); - const isDomainSetupVerified = - activeOrg?.organization.customDomain && - activeOrg?.organization.domainVerified; + const isPathActive = (path: string) => pathname.includes(path); + const isDomainSetupVerified = + activeOrg?.organization.customDomain && + activeOrg?.organization.domainVerified; - return ( - - - - - -
-
-
- -
-
-
- {!sidebarCollapsed && ( -

- {activeOrg?.organization.name ?? - "No organization found"} -

- )} - {!sidebarCollapsed && ( - - )} -
- {!sidebarCollapsed && ( - - -

- {isDomainSetupVerified - ? activeOrg?.organization.customDomain - : "No custom domain set"} -

- - )} -
-
-
- - - - No organizations found - - {orgData?.map((organization) => { - const isSelected = - activeOrg?.organization.id === - organization.organization.id; - return ( - { - await updateActiveOrganization( - organization.organization.id, - ); - setOpen(false); - router.push("/dashboard/caps"); - }} - > -
- -

- {organization.organization.name} -

-
- {isSelected && ( - - )} -
- ); - })} - - - -
-
-
-
-
-
-
- - - } - description="A new organization to share caps with your team" - > - - Create New Organization - - -
- setDialogOpen(false)} - formRef={formRef} - onNameChange={setOrganizationName} - /> -
- - - - -
-
- ); + toggleMobileNav?.()} /> +
+
+ + {!sidebarCollapsed && !user.isPro && ( + + + + )} + + toggleMobileNav?.()} + subscribed={user.isPro} + /> + {buildEnv.NEXT_PUBLIC_IS_CAP && ( +
+ + Earn 40% Referral + +
+ )} +

+ Cap Software, Inc. {new Date().getFullYear()}. +

+
+ + + } + description="A new organization to share caps with your team" + > + + Create New Organization + + +
+ setDialogOpen(false)} + formRef={formRef} + onNameChange={setOrganizationName} + /> +
+ + + + +
+ + ); }; const NavItem = ({ - name, - href, - icon, - sidebarCollapsed, - toggleMobileNav, - isPathActive, - extraText, + name, + href, + icon, + sidebarCollapsed, + toggleMobileNav, + isPathActive, + extraText, }: { - name: string; - href: string; - icon: React.ReactElement<{ - ref: RefObject; - className: string; - size: number; - }>; - sidebarCollapsed: boolean; - toggleMobileNav?: () => void; - isPathActive: (path: string) => boolean; - extraText: number | null | undefined; + name: string; + href: string; + icon: React.ReactElement<{ + ref: RefObject; + className: string; + size: number; + }>; + sidebarCollapsed: boolean; + toggleMobileNav?: () => void; + isPathActive: (path: string) => boolean; + extraText: number | null | undefined; }) => { - const iconRef = useRef(null); - return ( - - toggleMobileNav?.()} - onMouseEnter={() => { - iconRef.current?.startAnimation(); - }} - onMouseLeave={() => { - iconRef.current?.stopAnimation(); - }} - prefetch={false} - passHref - className={classNames( - "relative border border-transparent transition z-3", - sidebarCollapsed - ? "flex justify-center items-center px-0 w-full size-9" - : "px-3 py-2 w-full", - isPathActive(href) - ? "bg-transparent pointer-events-none" - : "hover:bg-gray-2", - "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none", - )} - > - {cloneElement(icon, { - ref: iconRef, - className: clsx( - sidebarCollapsed ? "text-gray-12 mx-auto" : "text-gray-10", - ), - size: sidebarCollapsed ? 18 : 16, - })} -

- {name} -

- {extraText !== null && !sidebarCollapsed && ( -

- {extraText} -

- )} - -
- ); + const iconRef = useRef(null); + return ( + + toggleMobileNav?.()} + onMouseEnter={() => { + iconRef.current?.startAnimation(); + }} + onMouseLeave={() => { + iconRef.current?.stopAnimation(); + }} + prefetch={false} + passHref + className={classNames( + "relative border border-transparent transition z-3", + sidebarCollapsed + ? "flex justify-center items-center px-0 w-full size-9" + : "px-3 py-2 w-full", + isPathActive(href) + ? "bg-transparent pointer-events-none" + : "hover:bg-gray-2", + "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none" + )} + > + {cloneElement(icon, { + ref: iconRef, + className: clsx( + sidebarCollapsed ? "text-gray-12 mx-auto" : "text-gray-10" + ), + size: sidebarCollapsed ? 18 : 16, + })} +

+ {name} +

+ {extraText !== null && !sidebarCollapsed && ( +

+ {extraText} +

+ )} + +
+ ); }; export default AdminNavItems; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx index 88de2a719e..d815238837 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx @@ -1,12 +1,71 @@ "use client"; -import { X, Maximize2, Circle, Square, RectangleHorizontal, FlipHorizontal } from "lucide-react"; +import { + X, + Maximize2, + Circle, + Square, + RectangleHorizontal, + FlipHorizontal, + PictureInPicture, +} from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import clsx from "clsx"; type CameraPreviewSize = "sm" | "lg"; type CameraPreviewShape = "round" | "square" | "full"; +type VideoDimensions = { + width: number; + height: number; +}; + +const WINDOW_PADDING = 20; +const BAR_HEIGHT = 52; + +const getPreviewMetrics = ( + previewSize: CameraPreviewSize, + previewShape: CameraPreviewShape, + dimensions: VideoDimensions | null +) => { + const base = previewSize === "sm" ? 230 : 400; + + if (!dimensions || dimensions.height === 0) { + return { + base, + width: base, + height: base, + aspectRatio: 1, + }; + } + + const aspectRatio = dimensions.width / dimensions.height; + + if (previewShape !== "full") { + return { + base, + width: base, + height: base, + aspectRatio, + }; + } + + if (aspectRatio >= 1) { + return { + base, + width: base * aspectRatio, + height: base, + aspectRatio, + }; + } + + return { + base, + width: base, + height: base / aspectRatio, + aspectRatio, + }; +}; interface CameraPreviewWindowProps { cameraId: string; @@ -20,15 +79,18 @@ export const CameraPreviewWindow = ({ const [size, setSize] = useState("sm"); const [shape, setShape] = useState("round"); const [mirrored, setMirrored] = useState(false); - const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const [position, setPosition] = useState<{ x: number; y: number } | null>( + null + ); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const videoRef = useRef(null); const streamRef = useRef(null); const containerRef = useRef(null); - const [videoDimensions, setVideoDimensions] = useState<{ width: number; height: number } | null>(null); + const [videoDimensions, setVideoDimensions] = + useState(null); const [mounted, setMounted] = useState(false); - const pipAutoEnteredRef = useRef(false); + const [isInPictureInPicture, setIsInPictureInPicture] = useState(false); useEffect(() => { setMounted(true); @@ -47,23 +109,10 @@ export const CameraPreviewWindow = ({ }); streamRef.current = stream; - + if (videoRef.current) { videoRef.current.srcObject = stream; } - - const calculateInitialPosition = () => { - const padding = 20; - const base = size === "sm" ? 230 : 400; - const barHeight = 52; - const windowWidth = base; - const windowHeight = base + barHeight; - const x = padding; - const y = window.innerHeight - windowHeight - padding; - setPosition({ x, y }); - }; - - setTimeout(calculateInitialPosition, 100); } catch (err) { console.error("Failed to start camera", err); } @@ -73,75 +122,73 @@ export const CameraPreviewWindow = ({ return () => { if (streamRef.current) { - streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current.getTracks().forEach((track) => { + track.stop(); + }); + streamRef.current = null; } }; }, [cameraId]); useEffect(() => { - if (videoRef.current && streamRef.current && !videoRef.current.srcObject) { - videoRef.current.srcObject = streamRef.current; - } - }, [position]); - - useEffect(() => { - if (position) { - const padding = 20; - const base = size === "sm" ? 230 : 400; - const barHeight = 52; - const windowWidth = base; - const windowHeight = base + barHeight; - - setPosition((prev) => { - if (!prev) return { x: padding, y: window.innerHeight - windowHeight - padding }; - const maxX = window.innerWidth - windowWidth; - const maxY = window.innerHeight - windowHeight; - return { - x: Math.max(0, Math.min(prev.x, maxX)), - y: Math.max(0, Math.min(prev.y, maxY)), - }; - }); - } - }, [size]); + const metrics = getPreviewMetrics(size, shape, videoDimensions); - const handleMouseDown = useCallback((e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest('[data-controls]')) { + if (typeof window === "undefined") { return; } - e.stopPropagation(); - e.preventDefault(); - setIsDragging(true); - setDragStart({ - x: e.clientX - (position?.x || 0), - y: e.clientY - (position?.y || 0), - }); - }, [position]); - - const handleMouseMove = useCallback((e: MouseEvent) => { - if (!isDragging) return; - - const newX = e.clientX - dragStart.x; - const newY = e.clientY - dragStart.y; - - const base = size === "sm" ? 230 : 400; - const barHeight = 52; - const aspectRatio = videoDimensions - ? videoDimensions.width / videoDimensions.height - : 1; - const windowWidth = - shape === "full" ? (aspectRatio >= 1 ? base * aspectRatio : base) : base; - const windowHeight = - shape === "full" ? (aspectRatio >= 1 ? base : base / aspectRatio) : base; - const totalWidth = windowWidth; - const totalHeight = windowHeight + barHeight; - const maxX = window.innerWidth - totalWidth; - const maxY = window.innerHeight - totalHeight; - - setPosition({ - x: Math.max(0, Math.min(newX, maxX)), - y: Math.max(0, Math.min(newY, maxY)), + + const totalHeight = metrics.height + BAR_HEIGHT; + const maxX = Math.max(0, window.innerWidth - metrics.width); + const maxY = Math.max(0, window.innerHeight - totalHeight); + + setPosition((prev) => { + const defaultX = WINDOW_PADDING; + const defaultY = window.innerHeight - totalHeight - WINDOW_PADDING; + const nextX = prev?.x ?? defaultX; + const nextY = prev?.y ?? defaultY; + + return { + x: Math.max(0, Math.min(nextX, maxX)), + y: Math.max(0, Math.min(nextY, maxY)), + }; }); - }, [isDragging, dragStart, size, shape, videoDimensions]); + }, [size, shape, videoDimensions]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("[data-controls]")) { + return; + } + e.stopPropagation(); + e.preventDefault(); + setIsDragging(true); + setDragStart({ + x: e.clientX - (position?.x || 0), + y: e.clientY - (position?.y || 0), + }); + }, + [position] + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + + const newX = e.clientX - dragStart.x; + const newY = e.clientY - dragStart.y; + + const metrics = getPreviewMetrics(size, shape, videoDimensions); + const totalHeight = metrics.height + BAR_HEIGHT; + const maxX = Math.max(0, window.innerWidth - metrics.width); + const maxY = Math.max(0, window.innerHeight - totalHeight); + + setPosition({ + x: Math.max(0, Math.min(newX, maxX)), + y: Math.max(0, Math.min(newY, maxY)), + }); + }, + [isDragging, dragStart, size, shape, videoDimensions] + ); const handleMouseUp = useCallback(() => { setIsDragging(false); @@ -159,7 +206,10 @@ export const CameraPreviewWindow = ({ }, [isDragging, handleMouseMove, handleMouseUp]); const handleClose = useCallback(async () => { - if (videoRef.current && document.pictureInPictureElement === videoRef.current) { + if ( + videoRef.current && + document.pictureInPictureElement === videoRef.current + ) { try { await document.exitPictureInPicture(); } catch (err) { @@ -169,72 +219,42 @@ export const CameraPreviewWindow = ({ onClose(); }, [onClose]); - useEffect(() => { - if (!videoRef.current || !videoDimensions) return; - + const handleTogglePictureInPicture = useCallback(async () => { const video = videoRef.current; + if (!video || !document.pictureInPictureEnabled) return; - const enterPictureInPicture = async () => { - if (!video || !document.pictureInPictureEnabled) return; - - const isAlreadyInPip = document.pictureInPictureElement === video; - if (isAlreadyInPip) return; - - try { - await video.requestPictureInPicture(); - pipAutoEnteredRef.current = true; - } catch (err) { - console.error("Failed to enter Picture-in-Picture", err); - } - }; - - const exitPictureInPicture = async () => { - if (!video || document.pictureInPictureElement !== video) return; - - try { + try { + if (document.pictureInPictureElement === video) { await document.exitPictureInPicture(); - pipAutoEnteredRef.current = false; - } catch (err) { - console.error("Failed to exit Picture-in-Picture", err); - } - }; - - const handleVisibilityChange = () => { - if (document.hidden) { - enterPictureInPicture(); - } else if (pipAutoEnteredRef.current) { - exitPictureInPicture(); + } else { + await video.requestPictureInPicture(); } - }; + } catch (err) { + console.error("Failed to toggle Picture-in-Picture", err); + } + }, []); - const handleWindowBlur = () => { - enterPictureInPicture(); - }; + useEffect(() => { + if (!videoRef.current || !videoDimensions) return; - const handleWindowFocus = () => { - if (pipAutoEnteredRef.current) { - exitPictureInPicture(); - } - }; + const video = videoRef.current; const handlePipEnter = () => { - pipAutoEnteredRef.current = true; + setIsInPictureInPicture(true); }; const handlePipLeave = () => { - pipAutoEnteredRef.current = false; + setIsInPictureInPicture(false); }; - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("blur", handleWindowBlur); - window.addEventListener("focus", handleWindowFocus); video.addEventListener("enterpictureinpicture", handlePipEnter); video.addEventListener("leavepictureinpicture", handlePipLeave); + if (document.pictureInPictureElement === video) { + setIsInPictureInPicture(true); + } + return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("blur", handleWindowBlur); - window.removeEventListener("focus", handleWindowFocus); video.removeEventListener("enterpictureinpicture", handlePipEnter); video.removeEventListener("leavepictureinpicture", handlePipLeave); }; @@ -244,31 +264,22 @@ export const CameraPreviewWindow = ({ return null; } - const base = size === "sm" ? 230 : 400; - const barHeight = 52; - const aspectRatio = videoDimensions - ? videoDimensions.width / videoDimensions.height - : 1; - - const windowWidth = - shape === "full" ? (aspectRatio >= 1 ? base * aspectRatio : base) : base; - const windowHeight = - shape === "full" ? (aspectRatio >= 1 ? base : base / aspectRatio) : base; - const totalHeight = windowHeight + barHeight; + const metrics = getPreviewMetrics(size, shape, videoDimensions); + const totalHeight = metrics.height + BAR_HEIGHT; const borderRadius = shape === "round" ? "9999px" : size === "sm" ? "3rem" : "4rem"; - return createPortal(
{ - e.stopPropagation(); - }} >
e.stopPropagation()} onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.stopPropagation(); + handleClose(); + } + }} > + {document.pictureInPictureEnabled && ( + + )}
@@ -356,8 +389,8 @@ export const CameraPreviewWindow = ({ shape === "round" ? "rounded-full" : "rounded-3xl" )} style={{ - width: shape === "full" ? `${windowWidth}px` : `${base}px`, - height: shape === "full" ? `${windowHeight}px` : `${base}px`, + width: `${metrics.width}px`, + height: `${metrics.height}px`, }} >
-
- , document.body); +
, + document.body + ); }; - diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx index 4cd639df3f..50a2a03b2f 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx @@ -20,6 +20,8 @@ interface CameraSelectorProps { availableCameras: MediaDeviceInfo[]; dialogOpen: boolean; disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; onCameraChange: (cameraId: string | null) => void; onRefreshDevices: () => Promise | void; } @@ -29,6 +31,8 @@ export const CameraSelector = ({ availableCameras, dialogOpen, disabled = false, + open, + onOpenChange, onCameraChange, onRefreshDevices, }: CameraSelectorProps) => { @@ -52,7 +56,7 @@ export const CameraSelector = ({ ); const handleStatusPillClick = async ( - event: MouseEvent + event: MouseEvent | KeyboardEvent ) => { if (!shouldRequestPermission) return; event.preventDefault(); @@ -69,6 +73,12 @@ export const CameraSelector = ({ } }; + const handleStatusPillKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + handleStatusPillClick(event); + } + }; + return (
- svg]:hidden", - disabled || shouldRequestPermission ? "cursor-default" : undefined - )} - onPointerDown={(event) => { - if (shouldRequestPermission) { - event.preventDefault(); - event.stopPropagation(); - } - }} - onKeyDown={(event: KeyboardEvent) => { - if (shouldRequestPermission) { - const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; - if (keys.includes(event.key)) { +
+ svg]:hidden", + disabled || shouldRequestPermission ? "cursor-default" : undefined + )} + onPointerDown={(event) => { + if (shouldRequestPermission) { event.preventDefault(); event.stopPropagation(); } - } - }} - aria-disabled={disabled || shouldRequestPermission} - > - + }} + onKeyDown={(event: KeyboardEvent) => { + if (shouldRequestPermission) { + const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; + if (keys.includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } + } + }} + aria-disabled={disabled || shouldRequestPermission} + > + + - +
diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx index a6a9083c80..9ef2358684 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx @@ -20,6 +20,8 @@ interface MicrophoneSelectorProps { availableMics: MediaDeviceInfo[]; dialogOpen: boolean; disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; onMicChange: (micId: string | null) => void; onRefreshDevices: () => Promise | void; } @@ -29,6 +31,8 @@ export const MicrophoneSelector = ({ availableMics, dialogOpen, disabled = false, + open, + onOpenChange, onMicChange, onRefreshDevices, }: MicrophoneSelectorProps) => { @@ -52,7 +56,7 @@ export const MicrophoneSelector = ({ ); const handleStatusPillClick = async ( - event: MouseEvent + event: MouseEvent | KeyboardEvent ) => { if (!shouldRequestPermission) return; event.preventDefault(); @@ -71,6 +75,12 @@ export const MicrophoneSelector = ({ } }; + const handleStatusPillKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + handleStatusPillClick(event); + } + }; + return (
- svg]:hidden", - disabled || shouldRequestPermission ? "cursor-default" : undefined - )} - onPointerDown={(event) => { - if (shouldRequestPermission) { - event.preventDefault(); - event.stopPropagation(); - } - }} - onKeyDown={(event: KeyboardEvent) => { - if (shouldRequestPermission) { - const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; - if (keys.includes(event.key)) { +
+ svg]:hidden", + disabled || shouldRequestPermission ? "cursor-default" : undefined + )} + onPointerDown={(event) => { + if (shouldRequestPermission) { event.preventDefault(); event.stopPropagation(); } - } - }} - aria-disabled={disabled || shouldRequestPermission} - > - + }} + onKeyDown={(event: KeyboardEvent) => { + if (shouldRequestPermission) { + const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; + if (keys.includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } + } + }} + aria-disabled={disabled || shouldRequestPermission} + > + + - +
diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx index 44520e43a2..97e287b45d 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx @@ -32,6 +32,8 @@ export const WebRecorderDialog = () => { const [recordingMode, setRecordingMode] = useState("fullscreen"); const [selectedCameraId, setSelectedCameraId] = useState(null); + const [cameraSelectOpen, setCameraSelectOpen] = useState(false); + const [micSelectOpen, setMicSelectOpen] = useState(false); const dialogContentRef = useRef(null); const { activeOrganization } = useDashboardContext(); @@ -254,6 +256,13 @@ export const WebRecorderDialog = () => { availableCameras={availableCameras} dialogOpen={open} disabled={isBusy} + open={cameraSelectOpen} + onOpenChange={(isOpen) => { + setCameraSelectOpen(isOpen); + if (isOpen) { + setMicSelectOpen(false); + } + }} onCameraChange={handleCameraChange} onRefreshDevices={refreshCameras} /> @@ -262,6 +271,13 @@ export const WebRecorderDialog = () => { availableMics={availableMics} dialogOpen={open} disabled={isBusy} + open={micSelectOpen} + onOpenChange={(isOpen) => { + setMicSelectOpen(isOpen); + if (isOpen) { + setCameraSelectOpen(false); + } + }} onMicChange={setSelectedMicId} onRefreshDevices={refreshMics} /> From 033ea90b02194a2a043a34c4d228f6d9a9ae4e6e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:08:43 +0000 Subject: [PATCH 04/56] feat: web-backend Videos update --- packages/web-backend/src/Videos/index.ts | 165 ++++++++++------------- tsconfig.json | 4 + 2 files changed, 72 insertions(+), 97 deletions(-) diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index f58c02ab74..6951af4709 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -3,13 +3,14 @@ import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub } from "@cap/utils"; import { CurrentUser, Folder, Policy, Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; -import { Array, Effect, Option, pipe } from "effect"; +import { Array, Context, Effect, Option, pipe } from "effect"; import type { Schema } from "effect/Schema"; import { Database } from "../Database.ts"; import { S3Buckets } from "../S3Buckets/index.ts"; import { VideosPolicy } from "./VideosPolicy.ts"; import { VideosRepo } from "./VideosRepo.ts"; +import type { CreateVideoInput as RepoCreateVideoInput } from "./VideosRepo.ts"; type UploadProgressUpdateInput = Schema.Type< typeof Video.UploadProgressUpdateInput @@ -45,11 +46,10 @@ export class Videos extends Effect.Service()("Videos", { * Delete a video. Will fail if the user does not have access. */ delete: Effect.fn("Videos.delete")(function* (videoId: Video.VideoId) { - const [video] = yield* repo - .getById(videoId) - .pipe( - Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())), - ); + const maybeVideo = yield* repo.getById(videoId); + if (Option.isNone(maybeVideo)) + return yield* Effect.fail(new Video.NotFoundError()); + const [video] = maybeVideo.value; const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId); @@ -59,9 +59,7 @@ export class Videos extends Effect.Service()("Videos", { yield* Effect.log(`Deleted video ${video.id}`); - const user = yield* CurrentUser; - - const prefix = `${user.id}/${video.id}/`; + const prefix = `${video.ownerId}/${video.id}/`; const listedObjects = yield* bucket.listObjects({ prefix }); @@ -81,12 +79,12 @@ export class Videos extends Effect.Service()("Videos", { duplicate: Effect.fn("Videos.duplicate")(function* ( videoId: Video.VideoId, ) { - const [video] = yield* repo + const maybeVideo = yield* repo .getById(videoId) - .pipe( - Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())), - Policy.withPolicy(policy.isOwner(videoId)), - ); + .pipe(Policy.withPolicy(policy.isOwner(videoId))); + if (Option.isNone(maybeVideo)) + return yield* Effect.fail(new Video.NotFoundError()); + const [video] = maybeVideo.value; const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId); @@ -133,24 +131,20 @@ export class Videos extends Effect.Service()("Videos", { ) .pipe(Policy.withPublicPolicy(policy.canView(videoId))); - return pipe( - result, - Option.fromNullable, - Option.map((r) => new Video.UploadProgress(r)), - ); + if (result == null) return Option.none(); + return Option.some(new Video.UploadProgress(result)); }), updateUploadProgress: Effect.fn("Videos.updateUploadProgress")(function* ( input: UploadProgressUpdateInput, ) { - const user = yield* CurrentUser; - const uploaded = Math.min(input.uploaded, input.total); const total = input.total; const updatedAt = input.updatedAt; const videoId = input.videoId; - const [record] = yield* db.use((db) => + const [record] = yield* db + .use((db) => db .select({ video: Db.videos, @@ -162,14 +156,12 @@ export class Videos extends Effect.Service()("Videos", { Dz.eq(Db.videos.id, Db.videoUploads.videoId), ) .where( - Dz.and( Dz.eq(Db.videos.id, videoId), - Dz.eq(Db.videos.ownerId, user.id), - ), ), - ); + ) + .pipe(Policy.withPolicy(policy.isOwner(videoId))); - if (!record) return yield* new Video.NotFoundError(); + if (!record) return yield* Effect.fail(new Video.NotFoundError()); yield* db.use((db) => db.transaction(async (tx) => { @@ -211,10 +203,10 @@ export class Videos extends Effect.Service()("Videos", { createInstantRecording: Effect.fn("Videos.createInstantRecording")( function* (input: InstantRecordingCreateInput) { - const user = yield* CurrentUser; + const user = yield* CurrentUser; if (user.activeOrganizationId !== input.orgId) - return yield* new Policy.PolicyDeniedError(); + return yield* Effect.fail(new Policy.PolicyDeniedError()); const [customBucket] = yield* db.use((db) => db @@ -234,20 +226,21 @@ export class Videos extends Effect.Service()("Videos", { month: "long", })} ${now.getFullYear()}`; - const videoId = yield* repo.create({ - ownerId: user.id, - orgId: input.orgId, - name: `Cap Recording - ${formattedDate}`, - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - source: { type: "desktopMP4" as const }, - bucketId, - folderId, - width, - height, - duration, - metadata: Option.none(), - transcriptionStatus: Option.none(), - }); + const createData = { + ownerId: user.id, + orgId: input.orgId, + name: `Cap Recording - ${formattedDate}`, + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + source: { type: "desktopMP4" as const }, + bucketId, + folderId, + width, + height, + duration, + metadata: Option.none(), + transcriptionStatus: Option.none(), + } as unknown as RepoCreateVideoInput; + const videoId = yield* repo.create(createData); if (input.supportsUploadProgress ?? true) yield* db.use((db) => @@ -310,76 +303,54 @@ export class Videos extends Effect.Service()("Videos", { getDownloadInfo: Effect.fn("Videos.getDownloadInfo")(function* ( videoId: Video.VideoId, ) { - const [video] = yield* repo + const maybeVideo = yield* repo .getById(videoId) - .pipe( - Effect.flatMap( - Effect.catchTag( - "NoSuchElementException", - () => new Video.NotFoundError(), - ), - ), - Policy.withPublicPolicy(policy.canView(videoId)), - ); + .pipe(Policy.withPublicPolicy(policy.canView(videoId))); + if (Option.isNone(maybeVideo)) + return yield* Effect.fail(new Video.NotFoundError()); + const [video] = maybeVideo.value; - const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId); + const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId); - return yield* Option.fromNullable(Video.Video.getSource(video)).pipe( - Option.filter((v) => v._tag === "Mp4Source"), - Option.map((v) => - bucket.getSignedObjectUrl(v.getFileKey()).pipe( - Effect.map((downloadUrl) => ({ - fileName: `${video.name}.mp4`, - downloadUrl, - })), - ), - ), - Effect.transposeOption, - ); + const src = Video.Video.getSource(video); + if (!src) return Option.none(); + if (!(src instanceof Video.Mp4Source)) return Option.none(); + + const downloadUrl = yield* bucket.getSignedObjectUrl(src.getFileKey()); + return Option.some({ fileName: `${video.name}.mp4`, downloadUrl }); }), getThumbnailURL: Effect.fn("Videos.getThumbnailURL")(function* ( videoId: Video.VideoId, ) { - const videoOpt = yield* repo + const maybeVideo = yield* repo .getById(videoId) .pipe(Policy.withPublicPolicy(policy.canView(videoId))); + if (Option.isNone(maybeVideo)) return Option.none(); + const [video] = maybeVideo.value; - return yield* videoOpt.pipe( - Effect.transposeMapOption( - Effect.fn(function* ([video]) { - const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId); - - const listResponse = yield* bucket.listObjects({ - prefix: `${video.ownerId}/${video.id}/`, - }); - const contents = listResponse.Contents || []; - - const thumbnailKey = contents.find((item) => - item.Key?.endsWith("screen-capture.jpg"), - )?.Key; - - if (!thumbnailKey) return Option.none(); - - return Option.some( - yield* bucket.getSignedObjectUrl(thumbnailKey), - ); - }), - ), - Effect.map(Option.flatten), - ); + const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId); + const listResponse = yield* bucket.listObjects({ + prefix: `${video.ownerId}/${video.id}/`, + }); + const contents = listResponse.Contents || []; + const thumbnailKey = contents.find((item) => + item.Key?.endsWith("screen-capture.jpg"), + )?.Key; + if (!thumbnailKey) return Option.none(); + const url = yield* bucket.getSignedObjectUrl(thumbnailKey); + return Option.some(url); }), getAnalytics: Effect.fn("Videos.getAnalytics")(function* ( videoId: Video.VideoId, ) { - const [video] = yield* getByIdForViewing(videoId).pipe( - Effect.flatten, - Effect.catchTag( - "NoSuchElementException", - () => new Video.NotFoundError(), - ), - ); + const maybeVideo = yield* repo + .getById(videoId) + .pipe(Policy.withPublicPolicy(policy.canView(videoId))); + if (Option.isNone(maybeVideo)) + return yield* Effect.fail(new Video.NotFoundError()); + const [video] = maybeVideo.value; const response = yield* Effect.tryPromise(() => dub().analytics.retrieve({ diff --git a/tsconfig.json b/tsconfig.json index c916f06840..025664217f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,10 @@ { "path": "./packages/ui" }, { "path": "./packages/ui-solid" }, { "path": "./packages/utils" }, + { "path": "./packages/web-backend" }, + { "path": "./packages/web-domain" }, + { "path": "./packages/database" }, + { "path": "./packages/env" }, { "path": "./apps/desktop" }, { "path": "./apps/web" } ], From bb2f923f0aae3ceeb7d0b4634bd368c5657c766c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:38:58 +0000 Subject: [PATCH 05/56] fix: picture in picture camera switch --- .../WebRecorderDialog/CameraPreviewWindow.tsx | 127 +++++++++++++++++- .../WebRecorderDialog/WebRecorderDialog.tsx | 2 +- tsconfig.json | 4 - 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx index d815238837..2178a7dadd 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx @@ -12,6 +12,7 @@ import { import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import clsx from "clsx"; +import { LoadingSpinner } from "@cap/ui"; type CameraPreviewSize = "sm" | "lg"; type CameraPreviewShape = "round" | "square" | "full"; @@ -91,6 +92,9 @@ export const CameraPreviewWindow = ({ useState(null); const [mounted, setMounted] = useState(false); const [isInPictureInPicture, setIsInPictureInPicture] = useState(false); + const autoPictureInPictureRef = useRef(false); + const isPictureInPictureSupported = + typeof document !== "undefined" && document.pictureInPictureEnabled; useEffect(() => { setMounted(true); @@ -211,6 +215,7 @@ export const CameraPreviewWindow = ({ document.pictureInPictureElement === videoRef.current ) { try { + autoPictureInPictureRef.current = false; await document.exitPictureInPicture(); } catch (err) { console.error("Failed to exit Picture-in-Picture", err); @@ -221,9 +226,10 @@ export const CameraPreviewWindow = ({ const handleTogglePictureInPicture = useCallback(async () => { const video = videoRef.current; - if (!video || !document.pictureInPictureEnabled) return; + if (!video || !isPictureInPictureSupported) return; try { + autoPictureInPictureRef.current = false; if (document.pictureInPictureElement === video) { await document.exitPictureInPicture(); } else { @@ -232,10 +238,11 @@ export const CameraPreviewWindow = ({ } catch (err) { console.error("Failed to toggle Picture-in-Picture", err); } - }, []); + }, [isPictureInPictureSupported]); useEffect(() => { - if (!videoRef.current || !videoDimensions) return; + if (!videoRef.current || !videoDimensions || !isPictureInPictureSupported) + return; const video = videoRef.current; @@ -244,6 +251,7 @@ export const CameraPreviewWindow = ({ }; const handlePipLeave = () => { + autoPictureInPictureRef.current = false; setIsInPictureInPicture(false); }; @@ -258,7 +266,97 @@ export const CameraPreviewWindow = ({ video.removeEventListener("enterpictureinpicture", handlePipEnter); video.removeEventListener("leavepictureinpicture", handlePipLeave); }; - }, [videoDimensions]); + }, [videoDimensions, isPictureInPictureSupported]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + if (!isPictureInPictureSupported) { + return; + } + + const handleVisibilityChange = () => { + const video = videoRef.current; + + if (!video || !videoDimensions) { + return; + } + + const currentElement = document.pictureInPictureElement; + + if ( + currentElement && + currentElement !== video && + document.visibilityState === "hidden" + ) { + return; + } + + if (document.visibilityState === "hidden") { + if (currentElement === video) { + return; + } + + video + .requestPictureInPicture() + .then(() => { + autoPictureInPictureRef.current = true; + }) + .catch((err) => { + autoPictureInPictureRef.current = false; + console.error( + "Failed to enter Picture-in-Picture on tab change", + err + ); + }); + + return; + } + + if ( + autoPictureInPictureRef.current && + currentElement === video && + document.visibilityState === "visible" + ) { + document + .exitPictureInPicture() + .catch((err) => { + console.error( + "Failed to exit Picture-in-Picture after returning", + err + ); + }) + .finally(() => { + autoPictureInPictureRef.current = false; + }); + return; + } + + autoPictureInPictureRef.current = false; + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [videoDimensions, isPictureInPictureSupported]); + + useEffect(() => { + return () => { + if ( + typeof document !== "undefined" && + videoRef.current && + document.pictureInPictureElement === videoRef.current + ) { + document.exitPictureInPicture().catch((err) => { + console.error("Failed to exit Picture-in-Picture on cleanup", err); + }); + } + }; + }, []); if (!mounted || !position) { return null; @@ -364,7 +462,7 @@ export const CameraPreviewWindow = ({ > - {document.pictureInPictureEnabled && ( + {isPictureInPictureSupported && ( diff --git a/tsconfig.json b/tsconfig.json index 025664217f..c916f06840 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,6 @@ { "path": "./packages/ui" }, { "path": "./packages/ui-solid" }, { "path": "./packages/utils" }, - { "path": "./packages/web-backend" }, - { "path": "./packages/web-domain" }, - { "path": "./packages/database" }, - { "path": "./packages/env" }, { "path": "./apps/desktop" }, { "path": "./apps/web" } ], From f698b3b7671bb877423b322a2b4b00a52eedf114 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:16:26 +0000 Subject: [PATCH 06/56] feat: open preferred option (e.g. Window or Display) --- .../WebRecorderDialog/WebRecorderDialog.tsx | 3 + .../WebRecorderDialog/useWebRecorder.ts | 171 +++++++++++++++++- 2 files changed, 170 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx index afc666f868..bd225088af 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx @@ -68,6 +68,9 @@ export const WebRecorderDialog = () => { micEnabled, recordingMode, selectedCameraId, + onRecordingSurfaceDetected: (mode) => { + setRecordingMode(mode); + }, }); const handleOpenChange = (next: boolean) => { diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts index 39935f9681..d9f3ee5960 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts @@ -26,6 +26,7 @@ interface UseWebRecorderOptions { recordingMode: RecordingMode; selectedCameraId: string | null; onPhaseChange?: (phase: RecorderPhase) => void; + onRecordingSurfaceDetected?: (mode: RecordingMode) => void; } const DISPLAY_MEDIA_VIDEO_CONSTRAINTS: MediaTrackConstraints = { @@ -49,11 +50,13 @@ const DISPLAY_MODE_PREFERENCES: Record< monitorTypeSurfaces: "include", selfBrowserSurface: "exclude", surfaceSwitching: "exclude", + preferCurrentTab: false, }, window: { monitorTypeSurfaces: "exclude", selfBrowserSurface: "exclude", surfaceSwitching: "exclude", + preferCurrentTab: false, }, tab: { monitorTypeSurfaces: "exclude", @@ -63,6 +66,79 @@ const DISPLAY_MODE_PREFERENCES: Record< }, }; +type DetectedDisplayRecordingMode = Exclude; + +const DISPLAY_SURFACE_TO_RECORDING_MODE: Record< + string, + DetectedDisplayRecordingMode +> = { + monitor: "fullscreen", + screen: "fullscreen", + window: "window", + application: "window", + browser: "tab", + tab: "tab", +}; + +const RECORDING_MODE_TO_DISPLAY_SURFACE: Record< + DetectedDisplayRecordingMode, + DisplaySurfacePreference +> = { + fullscreen: "monitor", + window: "window", + tab: "browser", +}; + +type DisplaySurfacePreference = + | "monitor" + | "window" + | "browser" + | "application"; + +const detectRecordingModeFromTrack = ( + track: MediaStreamTrack | null, + settings?: MediaTrackSettings +): DetectedDisplayRecordingMode | null => { + if (!track) return null; + + const trackSettings = settings ?? track.getSettings(); + const maybeDisplaySurface = ( + trackSettings as Partial<{ displaySurface?: unknown }> + ).displaySurface; + const rawSurface = + typeof maybeDisplaySurface === "string" ? maybeDisplaySurface : ""; + const normalizedSurface = rawSurface.toLowerCase(); + + if (normalizedSurface) { + const mapped = DISPLAY_SURFACE_TO_RECORDING_MODE[normalizedSurface]; + if (mapped) { + return mapped; + } + } + + const label = track.label?.toLowerCase() ?? ""; + + if ( + label.includes("screen") || + label.includes("display") || + label.includes("monitor") + ) { + return "fullscreen"; + } + + if (label.includes("window") || label.includes("application")) { + return "window"; + } + + if (label.includes("tab") || label.includes("browser")) { + return "tab"; + } + + return null; +}; + +const detectionRetryDelays = [120, 450, 1000]; + const shouldRetryDisplayMediaWithoutPreferences = (error: unknown) => { if (error instanceof DOMException) { return ( @@ -81,6 +157,7 @@ export const useWebRecorder = ({ recordingMode, selectedCameraId, onPhaseChange, + onRecordingSurfaceDetected, }: UseWebRecorderOptions) => { const [phase, setPhase] = useState("idle"); const [durationMs, setDurationMs] = useState(0); @@ -103,6 +180,9 @@ export const useWebRecorder = ({ null ); const stopRecordingRef = useRef<(() => Promise) | null>(null); + const recordingModeRef = useRef(recordingMode); + const detectionTimeoutsRef = useRef([]); + const detectionCleanupRef = useRef void>>([]); const rpc = useRpcClient(); const router = useRouter(); @@ -117,7 +197,23 @@ export const useWebRecorder = ({ [onPhaseChange] ); + const clearDetectionTracking = useCallback(() => { + detectionTimeoutsRef.current.forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + detectionTimeoutsRef.current = []; + detectionCleanupRef.current.forEach((cleanup) => { + try { + cleanup(); + } catch { + /* ignore */ + } + }); + detectionCleanupRef.current = []; + }, []); + const cleanupStreams = useCallback(() => { + clearDetectionTracking(); const stopTracks = (stream: MediaStream | null) => { stream?.getTracks().forEach((track) => { track.stop(); @@ -135,7 +231,7 @@ export const useWebRecorder = ({ if (videoRef.current) { videoRef.current.srcObject = null; } - }, []); + }, [clearDetectionTracking]); const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -144,6 +240,57 @@ export const useWebRecorder = ({ } }, []); + const notifyDetectedMode = useCallback( + (detected: DetectedDisplayRecordingMode | null) => { + if (!detected) return; + if (detected === recordingModeRef.current) return; + recordingModeRef.current = detected; + onRecordingSurfaceDetected?.(detected); + }, + [onRecordingSurfaceDetected] + ); + + const scheduleSurfaceDetection = useCallback( + (track: MediaStreamTrack | null, initialSettings?: MediaTrackSettings) => { + if (!track || !onRecordingSurfaceDetected) { + return; + } + + clearDetectionTracking(); + + const attemptDetection = (settingsOverride?: MediaTrackSettings) => { + notifyDetectedMode( + detectRecordingModeFromTrack(track, settingsOverride) + ); + }; + + attemptDetection(initialSettings); + + detectionRetryDelays.forEach((delay) => { + const timeoutId = window.setTimeout(() => { + attemptDetection(); + }, delay); + detectionTimeoutsRef.current.push(timeoutId); + }); + + const handleTrackReady = () => { + attemptDetection(); + }; + + track.addEventListener("unmute", handleTrackReady, { once: true }); + track.addEventListener("mute", handleTrackReady, { once: true }); + detectionCleanupRef.current.push(() => { + track.removeEventListener("unmute", handleTrackReady); + track.removeEventListener("mute", handleTrackReady); + }); + }, + [ + clearDetectionTracking, + notifyDetectedMode, + onRecordingSurfaceDetected, + ] + ); + const resetState = useCallback(() => { cleanupStreams(); clearTimer(); @@ -156,6 +303,10 @@ export const useWebRecorder = ({ setUploadStatus(undefined); }, [cleanupStreams, clearTimer, setUploadStatus, updatePhase]); + useEffect(() => { + recordingModeRef.current = recordingMode; + }, [recordingMode]); + useEffect(() => { return () => { resetState(); @@ -445,9 +596,16 @@ export const useWebRecorder = ({ cameraStreamRef.current = videoStream; firstTrack = videoStream.getVideoTracks()[0] ?? null; } else { - const baseDisplayRequest: DisplayMediaStreamOptions = { - video: { ...DISPLAY_MEDIA_VIDEO_CONSTRAINTS }, + const desiredSurface = + RECORDING_MODE_TO_DISPLAY_SURFACE[recordingMode as DetectedDisplayRecordingMode]; + const videoConstraints: MediaTrackConstraints & { + displaySurface?: DisplaySurfacePreference; + } = { ...DISPLAY_MEDIA_VIDEO_CONSTRAINTS, displaySurface: desiredSurface }; + + const baseDisplayRequest: ExtendedDisplayMediaStreamOptions = { + video: videoConstraints, audio: false, + preferCurrentTab: recordingMode === "tab", }; const preferredOptions = DISPLAY_MODE_PREFERENCES[recordingMode]; @@ -456,6 +614,7 @@ export const useWebRecorder = ({ const preferredDisplayRequest: DisplayMediaStreamOptions = { ...baseDisplayRequest, ...preferredOptions, + video: videoConstraints, }; try { @@ -487,6 +646,11 @@ export const useWebRecorder = ({ } const settings = firstTrack?.getSettings(); + + if (recordingMode !== "camera") { + scheduleSurfaceDetection(firstTrack, settings); + } + dimensionsRef.current = { width: settings?.width || undefined, height: settings?.height || undefined, @@ -763,4 +927,3 @@ export const useWebRecorder = ({ resetState, }; }; - From 46fe2ef7d7513db0f9a93ced138a5ad9dff95f42 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:03:23 +0000 Subject: [PATCH 07/56] feat: Settings dialog + auto select last device --- .../WebRecorderDialog/CameraSelector.tsx | 43 +++- .../WebRecorderDialog/MicrophoneSelector.tsx | 47 ++-- .../WebRecorderDialog/WebRecorderDialog.tsx | 241 +++++++++++++++++- 3 files changed, 298 insertions(+), 33 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx index 50a2a03b2f..eb4157d9c5 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx @@ -46,31 +46,46 @@ export const CameraSelector = ({ const shouldRequestPermission = permissionSupported && permissionState !== "granted"; + const statusPillDisabled = !shouldRequestPermission && !cameraEnabled; + const statusPillClassName = clsx( - "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none", + "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]", + statusPillDisabled ? "cursor-default" : "cursor-pointer", shouldRequestPermission ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" : cameraEnabled - ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)]" + ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)] hover:bg-[var(--blue-4)] dark:hover:bg-[var(--blue-5)]" : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" ); const handleStatusPillClick = async ( event: MouseEvent | KeyboardEvent ) => { - if (!shouldRequestPermission) return; - event.preventDefault(); - event.stopPropagation(); + if (shouldRequestPermission) { + event.preventDefault(); + event.stopPropagation(); - try { - const granted = await requestPermission(); - if (granted) { - await Promise.resolve(onRefreshDevices()); + try { + const granted = await requestPermission(); + if (granted) { + await Promise.resolve(onRefreshDevices()); + } + } catch (error) { + console.error("Camera permission request failed", error); + toast.error("Unable to access your camera. Check browser permissions."); } - } catch (error) { - console.error("Camera permission request failed", error); - toast.error("Unable to access your camera. Check browser permissions."); + + return; + } + + if (!cameraEnabled) { + return; } + + event.preventDefault(); + event.stopPropagation(); + + onCameraChange(null); }; const handleStatusPillKeyDown = (event: KeyboardEvent) => { @@ -124,8 +139,8 @@ export const CameraSelector = ({ statusPillClassName, "absolute right-[0.375rem] top-1/2 -translate-y-1/2 z-20" )} - disabled={!shouldRequestPermission} - aria-disabled={!shouldRequestPermission} + disabled={statusPillDisabled} + aria-disabled={statusPillDisabled} onClick={handleStatusPillClick} onKeyDown={handleStatusPillKeyDown} > diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx index 9ef2358684..d1ed825588 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx @@ -46,33 +46,48 @@ export const MicrophoneSelector = ({ const shouldRequestPermission = permissionSupported && permissionState !== "granted"; + const statusPillDisabled = !shouldRequestPermission && !micEnabled; + const statusPillClassName = clsx( - "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none", + "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]", + statusPillDisabled ? "cursor-default" : "cursor-pointer", shouldRequestPermission ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" : micEnabled - ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)]" + ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)] hover:bg-[var(--blue-4)] dark:hover:bg-[var(--blue-5)]" : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" ); const handleStatusPillClick = async ( event: MouseEvent | KeyboardEvent ) => { - if (!shouldRequestPermission) return; - event.preventDefault(); - event.stopPropagation(); + if (shouldRequestPermission) { + event.preventDefault(); + event.stopPropagation(); - try { - const granted = await requestPermission(); - if (granted) { - await Promise.resolve(onRefreshDevices()); + try { + const granted = await requestPermission(); + if (granted) { + await Promise.resolve(onRefreshDevices()); + } + } catch (error) { + console.error("Microphone permission request failed", error); + toast.error( + "Unable to access your microphone. Check browser permissions." + ); } - } catch (error) { - console.error("Microphone permission request failed", error); - toast.error( - "Unable to access your microphone. Check browser permissions." - ); + + return; + } + + if (!micEnabled) { + return; } + + event.preventDefault(); + event.stopPropagation(); + + onMicChange(null); }; const handleStatusPillKeyDown = (event: KeyboardEvent) => { @@ -126,8 +141,8 @@ export const MicrophoneSelector = ({ statusPillClassName, "absolute right-[0.375rem] top-1/2 -translate-y-1/2 z-20" )} - disabled={!shouldRequestPermission} - aria-disabled={!shouldRequestPermission} + disabled={statusPillDisabled} + aria-disabled={statusPillDisabled} onClick={handleStatusPillClick} onKeyDown={handleStatusPillKeyDown} > diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx index bd225088af..2a3aa9a258 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx @@ -6,11 +6,13 @@ import { DialogContent, DialogTitle, DialogTrigger, + Switch, } from "@cap/ui"; import { AnimatePresence, motion } from "framer-motion"; -import { MonitorIcon } from "lucide-react"; +import { ArrowLeftIcon, CircleHelpIcon, MonitorIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import CogIcon from "@/app/(org)/dashboard/_components/AnimatedIcons/Cog"; import { useDashboardContext } from "../../../Contexts"; import { CameraSelector } from "./CameraSelector"; import { CameraPreviewWindow } from "./CameraPreviewWindow"; @@ -26,14 +28,21 @@ import { useCameraDevices } from "./useCameraDevices"; import { useMicrophoneDevices } from "./useMicrophoneDevices"; import { useWebRecorder } from "./useWebRecorder"; +const REMEMBER_DEVICES_KEY = "cap-web-recorder-remember-devices"; +const PREFERRED_CAMERA_KEY = "cap-web-recorder-preferred-camera"; +const PREFERRED_MICROPHONE_KEY = "cap-web-recorder-preferred-microphone"; + export const WebRecorderDialog = () => { const [open, setOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [howItWorksOpen, setHowItWorksOpen] = useState(false); const [selectedMicId, setSelectedMicId] = useState(null); const [recordingMode, setRecordingMode] = useState("fullscreen"); const [selectedCameraId, setSelectedCameraId] = useState(null); const [cameraSelectOpen, setCameraSelectOpen] = useState(false); const [micSelectOpen, setMicSelectOpen] = useState(false); + const [rememberDevices, setRememberDevices] = useState(false); const dialogContentRef = useRef(null); const { activeOrganization } = useDashboardContext(); @@ -55,6 +64,55 @@ export const WebRecorderDialog = () => { } }, [recordingMode, selectedCameraId, availableCameras]); + useEffect(() => { + if (typeof window === "undefined") return; + + try { + const storedRemember = window.localStorage.getItem(REMEMBER_DEVICES_KEY); + if (storedRemember === "true") { + setRememberDevices(true); + } + } catch (error) { + console.error("Failed to load recorder preferences", error); + } + }, []); + + useEffect(() => { + if (!open || !rememberDevices) return; + if (typeof window === "undefined") return; + + try { + const storedCameraId = window.localStorage.getItem(PREFERRED_CAMERA_KEY); + if (storedCameraId) { + const hasCamera = availableCameras.some( + (camera) => camera.deviceId === storedCameraId + ); + if (hasCamera && storedCameraId !== selectedCameraId) { + setSelectedCameraId(storedCameraId); + } + } + + const storedMicId = window.localStorage.getItem(PREFERRED_MICROPHONE_KEY); + if (storedMicId) { + const hasMic = availableMics.some( + (microphone) => microphone.deviceId === storedMicId + ); + if (hasMic && storedMicId !== selectedMicId) { + setSelectedMicId(storedMicId); + } + } + } catch (error) { + console.error("Failed to restore recorder device selection", error); + } + }, [ + open, + rememberDevices, + availableCameras, + availableMics, + selectedCameraId, + selectedMicId, + ]); + const { isRecording, isBusy, @@ -83,6 +141,8 @@ export const WebRecorderDialog = () => { resetState(); setSelectedCameraId(null); setRecordingMode("fullscreen"); + setSettingsOpen(false); + setHowItWorksOpen(false); } setOpen(next); }; @@ -101,6 +161,69 @@ export const WebRecorderDialog = () => { const handleCameraChange = (cameraId: string | null) => { setSelectedCameraId(cameraId); + + if (!rememberDevices || typeof window === "undefined") { + return; + } + + try { + if (cameraId) { + window.localStorage.setItem(PREFERRED_CAMERA_KEY, cameraId); + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + } + } catch (error) { + console.error("Failed to persist preferred camera", error); + } + }; + + const handleMicChange = (micId: string | null) => { + setSelectedMicId(micId); + + if (!rememberDevices || typeof window === "undefined") { + return; + } + + try { + if (micId) { + window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, micId); + } else { + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } catch (error) { + console.error("Failed to persist preferred microphone", error); + } + }; + + const handleRememberDevicesChange = (next: boolean) => { + setRememberDevices(next); + + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(REMEMBER_DEVICES_KEY, next ? "true" : "false"); + + if (next) { + if (selectedCameraId) { + window.localStorage.setItem(PREFERRED_CAMERA_KEY, selectedCameraId); + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + } + + if (selectedMicId) { + window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, selectedMicId); + } else { + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } catch (error) { + console.error("Failed to update recorder preferences", error); + } }; return ( @@ -245,6 +368,107 @@ export const WebRecorderDialog = () => { exit="exit" className="relative flex justify-center flex-col p-[1rem] gap-[0.75rem] text-[0.875rem] font-[400] text-[--text-primary] bg-gray-2 rounded-lg min-h-[340px]" > + {!settingsOpen && ( + + )} + + {settingsOpen && ( + +
+ +

+ Recorder settings +

+ +
+
+
+
+

+ Remember selected webcam/microphone +

+

+ Automatically pick your last camera and mic when available. +

+
+ +
+
+
+ )} +
+ + {howItWorksOpen && ( + +
+ +

+ How it works +

+ +
+
+

+ If you're on a compatible browser, we upload your recording + in the background while you capture. +

+

+ When you stop, we finish processing instantly so you can grab a + shareable link right away. +

+

+ Selecting a camera enables picture-in-picture so your webcam + stays visible during the recording. For the best experience with + picture-in-picture, record in fullscreen. +

+
+
+ )} +
{ setCameraSelectOpen(false); } }} - onMicChange={setSelectedMicId} + onMicChange={handleMicChange} onRefreshDevices={refreshMics} /> { onStart={startRecording} onStop={handleStopClick} /> + )} @@ -298,7 +533,7 @@ export const WebRecorderDialog = () => { {selectedCameraId && ( setSelectedCameraId(null)} + onClose={() => handleCameraChange(null)} /> )} From 205e47eb7a4e39f8929ba47f3a7978d8220df138 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 23:30:26 +0000 Subject: [PATCH 08/56] feat: various styling bits for the web recorder --- .../_components/AnimatedIcons/Cog.tsx | 3 +- .../WebRecorderDialog/CameraPreviewWindow.tsx | 31 +++--- .../RecordingModeSelector.tsx | 39 ++++++-- .../WebRecorderDialog/WebRecorderDialog.tsx | 99 ++++++++++++++----- .../WebRecorderDialogHeader.tsx | 2 +- 5 files changed, 125 insertions(+), 49 deletions(-) diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Cog.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Cog.tsx index d825a12e04..94346a8ec5 100644 --- a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Cog.tsx +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Cog.tsx @@ -51,7 +51,8 @@ const CogIcon = forwardRef( ); return (
{ - const defaultX = WINDOW_PADDING; + const defaultX = window.innerWidth - metrics.width - WINDOW_PADDING; const defaultY = window.innerHeight - totalHeight - WINDOW_PADDING; const nextX = prev?.x ?? defaultX; const nextY = prev?.y ?? defaultY; @@ -496,12 +496,14 @@ export const CameraPreviewWindow = ({ autoPlay playsInline muted + disablePictureInPicture={false} + controlsList="nodownload nofullscreen noremoteplayback" className={clsx( "absolute inset-0 w-full h-full object-cover pointer-events-none", shape === "round" ? "rounded-full" : "rounded-3xl" )} style={ - videoDimensions + videoDimensions && !isInPictureInPicture ? { transform: mirrored ? "scaleX(-1)" : "scaleX(1)", } @@ -526,18 +528,21 @@ export const CameraPreviewWindow = ({
)} - {isPictureInPictureSupported && ( -
-
- + {isPictureInPictureSupported && isInPictureInPicture && ( +
+
Picture in Picture active +
)} diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx index 0ea0f31561..6808c01138 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx @@ -32,29 +32,37 @@ export const RecordingModeSelector = ({ RecordingMode, { label: string; + displayLabel: string; icon: LucideIcon; } > = { fullscreen: { - label: "Full Screen", + label: "Full Screen (Recommended)", + displayLabel: "Full Screen", icon: MonitorIcon, }, window: { label: "Window", + displayLabel: "Window", icon: RectangleHorizontal, }, tab: { label: "Current tab", + displayLabel: "Current tab", icon: Globe, }, camera: { label: "Camera only", + displayLabel: "Camera only", icon: CameraIcon, }, }; + const selectedOption = mode ? recordingModeOptions[mode] : null; + const SelectedIcon = selectedOption?.icon; + return ( -
+
{ @@ -62,21 +70,36 @@ export const RecordingModeSelector = ({ }} disabled={disabled} > - + + > + {selectedOption && SelectedIcon && ( + + + {selectedOption.displayLabel} + + )} + - + {Object.entries(recordingModeOptions).map(([value, option]) => { const OptionIcon = option.icon; + const isFullscreen = value === "fullscreen"; return ( - - - {option.label} + + + + {option.label} + + {isFullscreen && ( + + Recommended to capture camera window when picture in picture is activated + + )} ); diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx index 2a3aa9a258..5c67ea2086 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx @@ -9,7 +9,14 @@ import { Switch, } from "@cap/ui"; import { AnimatePresence, motion } from "framer-motion"; -import { ArrowLeftIcon, CircleHelpIcon, MonitorIcon } from "lucide-react"; +import { + ArrowLeftIcon, + CircleHelpIcon, + CloudUploadIcon, + LinkIcon, + MonitorIcon, + PictureInPictureIcon, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import CogIcon from "@/app/(org)/dashboard/_components/AnimatedIcons/Cog"; @@ -31,6 +38,29 @@ import { useWebRecorder } from "./useWebRecorder"; const REMEMBER_DEVICES_KEY = "cap-web-recorder-remember-devices"; const PREFERRED_CAMERA_KEY = "cap-web-recorder-preferred-camera"; const PREFERRED_MICROPHONE_KEY = "cap-web-recorder-preferred-microphone"; +const HOW_IT_WORKS_ITEMS = [ + { + title: "Uploads while you record", + description: + "On compatible browsers, your capture uploads in the background while you record. Otherwise, it records first and uploads right after you stop.", + Icon: CloudUploadIcon, + accent: "bg-blue-3 text-blue-11 dark:bg-blue-4 dark:text-blue-10", + }, + { + title: "Instant shareable link", + description: + "Stopping the recording finalizes the upload immediately so you can copy your link right away.", + Icon: LinkIcon, + accent: "bg-green-3 text-green-11 dark:bg-green-4 dark:text-green-10", + }, + { + title: "Keep your webcam visible", + description: + "On compatible browsers, selecting a camera opens a picture‑in‑picture window that’s captured when you record fullscreen. We recommend fullscreen to keep it on top. If PiP capture isn’t supported, your camera is limited to the Cap recorder page.", + Icon: PictureInPictureIcon, + accent: "bg-purple-3 text-purple-11 dark:bg-purple-4 dark:text-purple-10", + }, +] as const; export const WebRecorderDialog = () => { const [open, setOpen] = useState(false); @@ -203,7 +233,10 @@ export const WebRecorderDialog = () => { } try { - window.localStorage.setItem(REMEMBER_DEVICES_KEY, next ? "true" : "false"); + window.localStorage.setItem( + REMEMBER_DEVICES_KEY, + next ? "true" : "false" + ); if (next) { if (selectedCameraId) { @@ -366,7 +399,7 @@ export const WebRecorderDialog = () => { initial="hidden" animate="visible" exit="exit" - className="relative flex justify-center flex-col p-[1rem] gap-[0.75rem] text-[0.875rem] font-[400] text-[--text-primary] bg-gray-2 rounded-lg min-h-[340px]" + className="relative flex justify-center flex-col p-[1rem] pt-[2rem] gap-[0.75rem] text-[0.875rem] font-[400] text-[--text-primary] bg-gray-2 rounded-lg min-h-[350px]" > {!settingsOpen && ( -

+

How it works

- +
-
-

- If you're on a compatible browser, we upload your recording - in the background while you capture. -

-

- When you stop, we finish processing instantly so you can grab a - shareable link right away. -

-

- Selecting a camera enables picture-in-picture so your webcam - stays visible during the recording. For the best experience with - picture-in-picture, record in fullscreen. -

+
+
+ {HOW_IT_WORKS_ITEMS.map( + ({ title, description, Icon, accent }) => ( +
+
+
+ +
+
+

+ {title} +

+

+ {description} +

+
+
+
+ ) + )} +
)} @@ -520,10 +567,10 @@ export const WebRecorderDialog = () => { setHowItWorksOpen(true); setSettingsOpen(false); }} - className="flex items-center justify-center gap-1 text-xs font-medium text-blue-11 transition-colors hover:text-blue-12" + className="flex items-center justify-center gap-1 text-xs font-medium transition-colors hover:text-blue-12" > - How does it work? + How it works (tips) )} diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx index 73c70dc878..75024d22e5 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx @@ -15,7 +15,7 @@ export const WebRecorderDialogHeader = ({ const { user, setUpgradeModalOpen } = useDashboardContext(); const planLabel = user.isPro ? "Pro" : "Free"; const planClassName = clsx( - "ml-2 inline-flex items-center rounded-md px-1.5 py-0.5 text-[0.7rem] font-medium transition-colors", + "ml-2 inline-flex items-center rounded-full px-2 text-[0.7rem] font-medium transition-colors", user.isPro ? "bg-blue-9 text-gray-1" : "cursor-pointer bg-gray-3 text-gray-12 hover:bg-gray-4" From 00d511ff9efb95a7e8fc8ce21c8f8e2311549c79 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 23:49:38 +0000 Subject: [PATCH 09/56] feat: web recorder cleanup --- .../WebRecorderDialog/RecordingButton.tsx | 22 ++++++++++++++++--- .../WebRecorderDialog/useWebRecorder.ts | 2 +- .../caps/components/sendProgressUpdate.ts | 3 ++- apps/web/app/api/erpc/route.ts | 14 +++++++----- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx index 7837dbf634..8d8424c463 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@cap/ui"; -import { PlayIcon } from "lucide-react"; +import type { SVGProps } from "react"; interface RecordingButtonProps { isRecording: boolean; @@ -10,6 +10,23 @@ interface RecordingButtonProps { onStop: () => void; } +const InstantIcon = ({ className, ...props }: SVGProps) => ( + + + +); + export const RecordingButton = ({ isRecording, disabled = false, @@ -29,7 +46,7 @@ export const RecordingButton = ({ "Stop Recording" ) : ( <> - + Start recording )} @@ -37,4 +54,3 @@ export const RecordingButton = ({
); }; - diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts index d9f3ee5960..d1528b4baf 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts @@ -492,7 +492,7 @@ export const useWebRecorder = ({ async ( blob: Blob, upload: PresignedPost, - currentVideoId: string, + currentVideoId: VideoId, thumbnailPreviewUrl: string | undefined ) => new Promise((resolve, reject) => { diff --git a/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts b/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts index edf31eb377..751e21e8d7 100644 --- a/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts +++ b/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts @@ -1,8 +1,9 @@ import { EffectRuntime } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; +import type { VideoId } from "./WebRecorderDialog/web-recorder-types"; export const sendProgressUpdate = async ( - videoId: string, + videoId: VideoId, uploaded: number, total: number, ) => { diff --git a/apps/web/app/api/erpc/route.ts b/apps/web/app/api/erpc/route.ts index 63ab0463c6..89ec4aa760 100644 --- a/apps/web/app/api/erpc/route.ts +++ b/apps/web/app/api/erpc/route.ts @@ -5,13 +5,15 @@ import { RpcSerialization, RpcServer } from "@effect/rpc"; import { Layer } from "effect"; import { Dependencies } from "@/lib/server"; +const rpcLayer = Layer.mergeAll( + RpcAuthMiddlewareLive, + RpcsLive, + RpcSerialization.layerJson, + HttpServer.layerContext, +); + const { handler } = RpcServer.toWebHandler(Rpcs, { - layer: Layer.mergeAll( - RpcAuthMiddlewareLive, - RpcsLive, - RpcSerialization.layerJson, - HttpServer.layerContext, - ).pipe(Layer.provideMerge(Dependencies)), + layer: Layer.provide(Dependencies)(rpcLayer), }); export const GET = (r: Request) => handler(r); From 2d01b70edf26cb2a7e2de27acca92e55e8b8ae47 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 23:49:50 +0000 Subject: [PATCH 10/56] fmt --- apps/desktop/src/routes/editor/Player.tsx | 822 ++++---- .../src/routes/editor/Timeline/ClipTrack.tsx | 1534 +++++++------- .../dashboard/_components/Navbar/Items.tsx | 820 ++++---- .../WebRecorderDialog/CameraPreviewWindow.tsx | 1062 +++++----- .../WebRecorderDialog/CameraSelector.tsx | 295 +-- .../WebRecorderDialog/MicrophoneSelector.tsx | 299 +-- .../WebRecorderDialog/RecordingButton.tsx | 84 +- .../RecordingModeSelector.tsx | 187 +- .../WebRecorderDialog/WebRecorderDialog.tsx | 1120 +++++------ .../WebRecorderDialogHeader.tsx | 172 +- .../WebRecorderDialog/useCameraDevices.ts | 104 +- .../WebRecorderDialog/useMediaPermission.ts | 189 +- .../WebRecorderDialog/useMicrophoneDevices.ts | 102 +- .../WebRecorderDialog/useWebRecorder.ts | 1759 +++++++++-------- .../web-recorder-constants.ts | 49 +- .../WebRecorderDialog/web-recorder-types.ts | 15 +- packages/web-backend/src/Videos/VideosRpcs.ts | 32 +- packages/web-backend/src/Videos/index.ts | 113 +- packages/web-domain/src/Video.ts | 6 +- 19 files changed, 4381 insertions(+), 4383 deletions(-) diff --git a/apps/desktop/src/routes/editor/Player.tsx b/apps/desktop/src/routes/editor/Player.tsx index c97e23c6a8..79f8820ae2 100644 --- a/apps/desktop/src/routes/editor/Player.tsx +++ b/apps/desktop/src/routes/editor/Player.tsx @@ -13,425 +13,425 @@ import { useEditorShortcuts } from "./useEditorShortcuts"; import { formatTime } from "./utils"; export function Player() { - const { - project, - editorInstance, - setDialog, - totalDuration, - editorState, - setEditorState, - zoomOutLimit, - setProject, - } = useEditorContext(); - - // Load captions on mount - onMount(async () => { - if (editorInstance && editorInstance.path) { - // Still load captions into the store since they will be used by the GPU renderer - await captionsStore.loadCaptions(editorInstance.path); - - // Synchronize captions settings with project configuration - // This ensures the GPU renderer will receive the caption settings - if (editorInstance && project) { - const updatedProject = { ...project }; - - // Add captions data to project configuration if it doesn't exist - if ( - !updatedProject.captions && - captionsStore.state.segments.length > 0 - ) { - updatedProject.captions = { - segments: captionsStore.state.segments.map((segment) => ({ - id: segment.id, - start: segment.start, - end: segment.end, - text: segment.text, - })), - settings: { - enabled: captionsStore.state.settings.enabled, - font: captionsStore.state.settings.font, - size: captionsStore.state.settings.size, - color: captionsStore.state.settings.color, - backgroundColor: captionsStore.state.settings.backgroundColor, - backgroundOpacity: captionsStore.state.settings.backgroundOpacity, - position: captionsStore.state.settings.position, - bold: captionsStore.state.settings.bold, - italic: captionsStore.state.settings.italic, - outline: captionsStore.state.settings.outline, - outlineColor: captionsStore.state.settings.outlineColor, - exportWithSubtitles: - captionsStore.state.settings.exportWithSubtitles, - }, - }; - - // Update the project with captions data - setProject(updatedProject); - - // Save the updated project configuration - await commands.setProjectConfig(updatedProject); - } - } - } - }); - - // Continue to update current caption when playback time changes - // This is still needed for CaptionsTab to highlight the current caption - createEffect(() => { - const time = editorState.playbackTime; - // Only update captions if we have a valid time and segments exist - if ( - time !== undefined && - time >= 0 && - captionsStore.state.segments.length > 0 - ) { - captionsStore.updateCurrentCaption(time); - } - }); - - const [canvasContainerRef, setCanvasContainerRef] = - createSignal(); - const containerBounds = createElementBounds(canvasContainerRef); - - const isAtEnd = () => { - const total = totalDuration(); - return total > 0 && total - editorState.playbackTime <= 0.1; - }; - - const cropDialogHandler = () => { - const display = editorInstance.recordings.segments[0].display; - setDialog({ - open: true, - type: "crop", - position: { - ...(project.background.crop?.position ?? { x: 0, y: 0 }), - }, - size: { - ...(project.background.crop?.size ?? { - x: display.width, - y: display.height, - }), - }, - }); - }; - - createEffect(() => { - if (isAtEnd() && editorState.playing) { - commands.stopPlayback(); - setEditorState("playing", false); - } - }); - - const handlePlayPauseClick = async () => { - try { - if (isAtEnd()) { - await commands.stopPlayback(); - setEditorState("playbackTime", 0); - await commands.seekTo(0); - await commands.startPlayback(FPS, OUTPUT_SIZE); - setEditorState("playing", true); - } else if (editorState.playing) { - await commands.stopPlayback(); - setEditorState("playing", false); - } else { - // Ensure we seek to the current playback time before starting playback - await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); - await commands.startPlayback(FPS, OUTPUT_SIZE); - setEditorState("playing", true); - } - if (editorState.playing) setEditorState("previewTime", null); - } catch (error) { - console.error("Error handling play/pause:", error); - setEditorState("playing", false); - } - }; - - // Register keyboard shortcuts in one place - useEditorShortcuts(() => { - const el = document.activeElement; - if (!el) return true; - const tagName = el.tagName.toLowerCase(); - const isContentEditable = el.getAttribute("contenteditable") === "true"; - return !( - tagName === "input" || - tagName === "textarea" || - isContentEditable - ); - }, [ - { - combo: "S", - handler: () => - setEditorState( - "timeline", - "interactMode", - editorState.timeline.interactMode === "split" ? "seek" : "split" - ), - }, - { - combo: "Mod+=", - handler: () => - editorState.timeline.transform.updateZoom( - editorState.timeline.transform.zoom / 1.1, - editorState.playbackTime - ), - }, - { - combo: "Mod+-", - handler: () => - editorState.timeline.transform.updateZoom( - editorState.timeline.transform.zoom * 1.1, - editorState.playbackTime - ), - }, - { - combo: "Space", - handler: async () => { - const prevTime = editorState.previewTime; - - if (!editorState.playing) { - if (prevTime !== null) setEditorState("playbackTime", prevTime); - - await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); - } - - await handlePlayPauseClick(); - }, - }, - ]); - - return ( -
-
- - cropDialogHandler()} - leftIcon={} - > - Crop - -
- -
-
-
-
- - - - - -
-
-
- - tooltipText="Toggle Split" - kbd={["S"]} - pressed={editorState.timeline.interactMode === "split"} - onChange={(v: boolean) => - setEditorState("timeline", "interactMode", v ? "split" : "seek") - } - as={KToggleButton} - variant="danger" - leftIcon={ - - } - /> -
- - { - editorState.timeline.transform.updateZoom( - editorState.timeline.transform.zoom * 1.1, - editorState.playbackTime - ); - }} - class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70" - /> - - - { - editorState.timeline.transform.updateZoom( - editorState.timeline.transform.zoom / 1.1, - editorState.playbackTime - ); - }} - class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70" - /> - - { - editorState.timeline.transform.updateZoom( - (1 - v) * zoomOutLimit(), - editorState.playbackTime - ); - }} - formatTooltip={() => - `${editorState.timeline.transform.zoom.toFixed( - 0 - )} seconds visible` - } - /> -
-
-
- ); + const { + project, + editorInstance, + setDialog, + totalDuration, + editorState, + setEditorState, + zoomOutLimit, + setProject, + } = useEditorContext(); + + // Load captions on mount + onMount(async () => { + if (editorInstance && editorInstance.path) { + // Still load captions into the store since they will be used by the GPU renderer + await captionsStore.loadCaptions(editorInstance.path); + + // Synchronize captions settings with project configuration + // This ensures the GPU renderer will receive the caption settings + if (editorInstance && project) { + const updatedProject = { ...project }; + + // Add captions data to project configuration if it doesn't exist + if ( + !updatedProject.captions && + captionsStore.state.segments.length > 0 + ) { + updatedProject.captions = { + segments: captionsStore.state.segments.map((segment) => ({ + id: segment.id, + start: segment.start, + end: segment.end, + text: segment.text, + })), + settings: { + enabled: captionsStore.state.settings.enabled, + font: captionsStore.state.settings.font, + size: captionsStore.state.settings.size, + color: captionsStore.state.settings.color, + backgroundColor: captionsStore.state.settings.backgroundColor, + backgroundOpacity: captionsStore.state.settings.backgroundOpacity, + position: captionsStore.state.settings.position, + bold: captionsStore.state.settings.bold, + italic: captionsStore.state.settings.italic, + outline: captionsStore.state.settings.outline, + outlineColor: captionsStore.state.settings.outlineColor, + exportWithSubtitles: + captionsStore.state.settings.exportWithSubtitles, + }, + }; + + // Update the project with captions data + setProject(updatedProject); + + // Save the updated project configuration + await commands.setProjectConfig(updatedProject); + } + } + } + }); + + // Continue to update current caption when playback time changes + // This is still needed for CaptionsTab to highlight the current caption + createEffect(() => { + const time = editorState.playbackTime; + // Only update captions if we have a valid time and segments exist + if ( + time !== undefined && + time >= 0 && + captionsStore.state.segments.length > 0 + ) { + captionsStore.updateCurrentCaption(time); + } + }); + + const [canvasContainerRef, setCanvasContainerRef] = + createSignal(); + const containerBounds = createElementBounds(canvasContainerRef); + + const isAtEnd = () => { + const total = totalDuration(); + return total > 0 && total - editorState.playbackTime <= 0.1; + }; + + const cropDialogHandler = () => { + const display = editorInstance.recordings.segments[0].display; + setDialog({ + open: true, + type: "crop", + position: { + ...(project.background.crop?.position ?? { x: 0, y: 0 }), + }, + size: { + ...(project.background.crop?.size ?? { + x: display.width, + y: display.height, + }), + }, + }); + }; + + createEffect(() => { + if (isAtEnd() && editorState.playing) { + commands.stopPlayback(); + setEditorState("playing", false); + } + }); + + const handlePlayPauseClick = async () => { + try { + if (isAtEnd()) { + await commands.stopPlayback(); + setEditorState("playbackTime", 0); + await commands.seekTo(0); + await commands.startPlayback(FPS, OUTPUT_SIZE); + setEditorState("playing", true); + } else if (editorState.playing) { + await commands.stopPlayback(); + setEditorState("playing", false); + } else { + // Ensure we seek to the current playback time before starting playback + await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); + await commands.startPlayback(FPS, OUTPUT_SIZE); + setEditorState("playing", true); + } + if (editorState.playing) setEditorState("previewTime", null); + } catch (error) { + console.error("Error handling play/pause:", error); + setEditorState("playing", false); + } + }; + + // Register keyboard shortcuts in one place + useEditorShortcuts(() => { + const el = document.activeElement; + if (!el) return true; + const tagName = el.tagName.toLowerCase(); + const isContentEditable = el.getAttribute("contenteditable") === "true"; + return !( + tagName === "input" || + tagName === "textarea" || + isContentEditable + ); + }, [ + { + combo: "S", + handler: () => + setEditorState( + "timeline", + "interactMode", + editorState.timeline.interactMode === "split" ? "seek" : "split", + ), + }, + { + combo: "Mod+=", + handler: () => + editorState.timeline.transform.updateZoom( + editorState.timeline.transform.zoom / 1.1, + editorState.playbackTime, + ), + }, + { + combo: "Mod+-", + handler: () => + editorState.timeline.transform.updateZoom( + editorState.timeline.transform.zoom * 1.1, + editorState.playbackTime, + ), + }, + { + combo: "Space", + handler: async () => { + const prevTime = editorState.previewTime; + + if (!editorState.playing) { + if (prevTime !== null) setEditorState("playbackTime", prevTime); + + await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); + } + + await handlePlayPauseClick(); + }, + }, + ]); + + return ( +
+
+ + cropDialogHandler()} + leftIcon={} + > + Crop + +
+ +
+
+
+
+ + + + + +
+
+
+ + tooltipText="Toggle Split" + kbd={["S"]} + pressed={editorState.timeline.interactMode === "split"} + onChange={(v: boolean) => + setEditorState("timeline", "interactMode", v ? "split" : "seek") + } + as={KToggleButton} + variant="danger" + leftIcon={ + + } + /> +
+ + { + editorState.timeline.transform.updateZoom( + editorState.timeline.transform.zoom * 1.1, + editorState.playbackTime, + ); + }} + class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70" + /> + + + { + editorState.timeline.transform.updateZoom( + editorState.timeline.transform.zoom / 1.1, + editorState.playbackTime, + ); + }} + class="text-gray-12 size-5 will-change-[opacity] transition-opacity hover:opacity-70" + /> + + { + editorState.timeline.transform.updateZoom( + (1 - v) * zoomOutLimit(), + editorState.playbackTime, + ); + }} + formatTooltip={() => + `${editorState.timeline.transform.zoom.toFixed( + 0, + )} seconds visible` + } + /> +
+
+
+ ); } // CSS for checkerboard grid (adaptive to light/dark mode) const gridStyle = { - "background-image": - "linear-gradient(45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + - "linear-gradient(-45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + - "linear-gradient(45deg, transparent 75%, rgba(128,128,128,0.12) 75%), " + - "linear-gradient(-45deg, transparent 75%, rgba(128,128,128,0.12) 75%)", - "background-size": "40px 40px", - "background-position": "0 0, 0 20px, 20px -20px, -20px 0px", - "background-color": "rgba(200,200,200,0.08)", + "background-image": + "linear-gradient(45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + + "linear-gradient(-45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " + + "linear-gradient(45deg, transparent 75%, rgba(128,128,128,0.12) 75%), " + + "linear-gradient(-45deg, transparent 75%, rgba(128,128,128,0.12) 75%)", + "background-size": "40px 40px", + "background-position": "0 0, 0 20px, 20px -20px, -20px 0px", + "background-color": "rgba(200,200,200,0.08)", }; function PreviewCanvas() { - const { latestFrame } = useEditorContext(); - - let canvasRef: HTMLCanvasElement | undefined; - - const [canvasContainerRef, setCanvasContainerRef] = - createSignal(); - const containerBounds = createElementBounds(canvasContainerRef); - - createEffect(() => { - const frame = latestFrame(); - if (!frame) return; - if (!canvasRef) return; - const ctx = canvasRef.getContext("2d"); - ctx?.putImageData(frame.data, 0, 0); - }); - - return ( -
- - {(currentFrame) => { - const padding = 4; - - const containerAspect = () => { - if (containerBounds.width && containerBounds.height) { - return ( - (containerBounds.width - padding * 2) / - (containerBounds.height - padding * 2) - ); - } - - return 1; - }; - - const frameAspect = () => - currentFrame().width / currentFrame().data.height; - - const size = () => { - if (frameAspect() < containerAspect()) { - const height = (containerBounds.height ?? 0) - padding * 1; - - return { - width: height * frameAspect(), - height, - }; - } - - const width = (containerBounds.width ?? 0) - padding * 2; - - return { - width, - height: width / frameAspect(), - }; - }; - - return ( -
- -
- ); - }} -
-
- ); + const { latestFrame } = useEditorContext(); + + let canvasRef: HTMLCanvasElement | undefined; + + const [canvasContainerRef, setCanvasContainerRef] = + createSignal(); + const containerBounds = createElementBounds(canvasContainerRef); + + createEffect(() => { + const frame = latestFrame(); + if (!frame) return; + if (!canvasRef) return; + const ctx = canvasRef.getContext("2d"); + ctx?.putImageData(frame.data, 0, 0); + }); + + return ( +
+ + {(currentFrame) => { + const padding = 4; + + const containerAspect = () => { + if (containerBounds.width && containerBounds.height) { + return ( + (containerBounds.width - padding * 2) / + (containerBounds.height - padding * 2) + ); + } + + return 1; + }; + + const frameAspect = () => + currentFrame().width / currentFrame().data.height; + + const size = () => { + if (frameAspect() < containerAspect()) { + const height = (containerBounds.height ?? 0) - padding * 1; + + return { + width: height * frameAspect(), + height, + }; + } + + const width = (containerBounds.width ?? 0) - padding * 2; + + return { + width, + height: width / frameAspect(), + }; + }; + + return ( +
+ +
+ ); + }} +
+
+ ); } function Time(props: { seconds: number; fps?: number; class?: string }) { - return ( - - {formatTime(props.seconds, props.fps ?? FPS)} - - ); + return ( + + {formatTime(props.seconds, props.fps ?? FPS)} + + ); } diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index 51be547ac5..825308b5d0 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -1,19 +1,19 @@ import { - createEventListener, - createEventListenerMap, + createEventListener, + createEventListenerMap, } from "@solid-primitives/event-listener"; import { cx } from "cva"; import { - type ComponentProps, - createEffect, - createMemo, - createRoot, - createSignal, - For, - Match, - onCleanup, - Show, - Switch, + type ComponentProps, + createEffect, + createMemo, + createRoot, + createSignal, + For, + Match, + onCleanup, + Show, + Switch, } from "solid-js"; import { produce } from "solid-js/store"; @@ -22,782 +22,782 @@ import { useEditorContext } from "../context"; import { useSegmentContext, useTimelineContext } from "./context"; import { getSectionMarker } from "./sectionMarker"; import { - SegmentContent, - SegmentHandle, - SegmentRoot, - TrackRoot, - useSegmentTranslateX, - useSegmentWidth, + SegmentContent, + SegmentHandle, + SegmentRoot, + TrackRoot, + useSegmentTranslateX, + useSegmentWidth, } from "./Track"; function formatTime(totalSeconds: number): string { - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = Math.floor(totalSeconds % 60); - - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; - } else if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } else { - return `${seconds}s`; - } + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = Math.floor(totalSeconds % 60); + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } else { + return `${seconds}s`; + } } function WaveformCanvas(props: { - systemWaveform?: number[]; - micWaveform?: number[]; - segment: { start: number; end: number }; - secsPerPixel: number; + systemWaveform?: number[]; + micWaveform?: number[]; + segment: { start: number; end: number }; + secsPerPixel: number; }) { - const { project } = useEditorContext(); - - let canvas: HTMLCanvasElement | undefined; - const { width } = useSegmentContext(); - const { secsPerPixel } = useTimelineContext(); - - const render = ( - ctx: CanvasRenderingContext2D, - h: number, - waveform: number[], - color: string, - gain = 0 - ) => { - const maxAmplitude = h; - - // yellow please - ctx.fillStyle = color; - ctx.beginPath(); - - const step = 0.05 / secsPerPixel(); - - ctx.moveTo(0, h); - - const norm = (w: number) => { - const ww = Number.isFinite(w) ? w : -60; - return 1.0 - Math.max(ww + gain, -60) / -60; - }; - - for ( - let segmentTime = props.segment.start; - segmentTime <= props.segment.end + 0.1; - segmentTime += 0.1 - ) { - const index = Math.floor(segmentTime * 10); - const xTime = index / 10; - - const currentDb = - typeof waveform[index] === "number" ? waveform[index] : -60; - const amplitude = norm(currentDb) * maxAmplitude; - - const x = (xTime - props.segment.start) / secsPerPixel(); - const y = h - amplitude; - - const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); - const prevDb = - typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; - const prevAmplitude = norm(prevDb) * maxAmplitude; - const prevY = h - prevAmplitude; - - const cpX1 = prevX + step / 2; - const cpX2 = x - step / 2; - - ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); - } - - ctx.lineTo( - (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), - h - ); - - ctx.closePath(); - ctx.fill(); - }; - - function renderWaveforms() { - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const w = width(); - if (w <= 0) return; - - const h = canvas.height; - canvas.width = w; - ctx.clearRect(0, 0, w, h); - - if (props.micWaveform) - render( - ctx, - h, - props.micWaveform, - "rgba(255,255,255,0.4)", - project.audio.micVolumeDb - ); - - if (props.systemWaveform) - render( - ctx, - h, - props.systemWaveform, - "rgba(255,150,0,0.5)", - project.audio.systemVolumeDb - ); - } - - createEffect(() => { - renderWaveforms(); - }); - - return ( - { - canvas = el; - renderWaveforms(); - }} - class="absolute inset-0 w-full h-full pointer-events-none" - height={52} - /> - ); + const { project } = useEditorContext(); + + let canvas: HTMLCanvasElement | undefined; + const { width } = useSegmentContext(); + const { secsPerPixel } = useTimelineContext(); + + const render = ( + ctx: CanvasRenderingContext2D, + h: number, + waveform: number[], + color: string, + gain = 0, + ) => { + const maxAmplitude = h; + + // yellow please + ctx.fillStyle = color; + ctx.beginPath(); + + const step = 0.05 / secsPerPixel(); + + ctx.moveTo(0, h); + + const norm = (w: number) => { + const ww = Number.isFinite(w) ? w : -60; + return 1.0 - Math.max(ww + gain, -60) / -60; + }; + + for ( + let segmentTime = props.segment.start; + segmentTime <= props.segment.end + 0.1; + segmentTime += 0.1 + ) { + const index = Math.floor(segmentTime * 10); + const xTime = index / 10; + + const currentDb = + typeof waveform[index] === "number" ? waveform[index] : -60; + const amplitude = norm(currentDb) * maxAmplitude; + + const x = (xTime - props.segment.start) / secsPerPixel(); + const y = h - amplitude; + + const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); + const prevDb = + typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; + const prevAmplitude = norm(prevDb) * maxAmplitude; + const prevY = h - prevAmplitude; + + const cpX1 = prevX + step / 2; + const cpX2 = x - step / 2; + + ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); + } + + ctx.lineTo( + (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), + h, + ); + + ctx.closePath(); + ctx.fill(); + }; + + function renderWaveforms() { + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = width(); + if (w <= 0) return; + + const h = canvas.height; + canvas.width = w; + ctx.clearRect(0, 0, w, h); + + if (props.micWaveform) + render( + ctx, + h, + props.micWaveform, + "rgba(255,255,255,0.4)", + project.audio.micVolumeDb, + ); + + if (props.systemWaveform) + render( + ctx, + h, + props.systemWaveform, + "rgba(255,150,0,0.5)", + project.audio.systemVolumeDb, + ); + } + + createEffect(() => { + renderWaveforms(); + }); + + return ( + { + canvas = el; + renderWaveforms(); + }} + class="absolute inset-0 w-full h-full pointer-events-none" + height={52} + /> + ); } export function ClipTrack( - props: Pick, "ref"> & { - handleUpdatePlayhead: (e: MouseEvent) => void; - } + props: Pick, "ref"> & { + handleUpdatePlayhead: (e: MouseEvent) => void; + }, ) { - const { - project, - setProject, - projectActions, - editorInstance, - projectHistory, - editorState, - setEditorState, - totalDuration, - micWaveforms, - systemAudioWaveforms, - } = useEditorContext(); - - const { secsPerPixel, duration } = useTimelineContext(); - - const segments = (): Array => - project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; - - function onHandleReleased() { - const { transform } = editorState.timeline; - - if (transform.position + transform.zoom > totalDuration() + 4) { - transform.updateZoom(totalDuration(), editorState.previewTime!); - } - } - - const hasMultipleRecordingSegments = () => - editorInstance.recordings.segments.length > 1; - - const split = () => editorState.timeline.interactMode === "split"; - - return ( - setEditorState("timeline", "hoveredTrack", "clip")} - onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} - > - - {(segment, i) => { - const [startHandleDrag, setStartHandleDrag] = createSignal(null); - - const prefixOffsets = createMemo(() => { - const segs = segments(); - const out: number[] = new Array(segs.length); - let sum = 0; - for (let k = 0; k < segs.length; k++) { - out[k] = sum; - sum += (segs[k].end - segs[k].start) / segs[k].timescale; - } - return out; - }); - const prevDuration = createMemo(() => prefixOffsets()[i()] ?? 0); - - const relativeSegment = createMemo(() => { - const ds = startHandleDrag(); - const offset = ds ? ds.offset / segment.timescale : 0; - - return { - start: Math.max(prevDuration() + offset, 0), - end: - prevDuration() + - offset + - (segment.end - segment.start) / segment.timescale, - timescale: segment.timescale, - recordingSegment: segment.recordingSegment, - }; - }); - - const segmentX = useSegmentTranslateX(relativeSegment); - const segmentWidth = useSegmentWidth(relativeSegment); - - const segmentRecording = (s = i()) => - editorInstance.recordings.segments[ - segments()[s].recordingSegment ?? 0 - ]; - - const marker = useSectionMarker(() => ({ - segments: segments(), - i: i(), - position: "left", - })); - - const endMarker = useSectionMarker(() => ({ - segments: segments(), - i: i(), - position: "right", - })); - - const isSelected = createMemo(() => { - const selection = editorState.timeline.selection; - if (!selection || selection.type !== "clip") return false; - - const segmentIndex = project.timeline?.segments?.findIndex( - (s) => s.start === segment.start && s.end === segment.end - ); - - if (segmentIndex === undefined || segmentIndex === -1) return false; - - return selection.indices.includes(segmentIndex); - }); - - const micWaveform = () => { - if (project.audio.micVolumeDb && project.audio.micVolumeDb < -30) - return; - - const idx = segment.recordingSegment ?? i(); - return micWaveforms()?.[idx] ?? []; - }; - - const systemAudioWaveform = () => { - if ( - project.audio.systemVolumeDb && - project.audio.systemVolumeDb <= -30 - ) - return; - - const idx = segment.recordingSegment ?? i(); - return systemAudioWaveforms()?.[idx] ?? []; - }; - - return ( - <> - - {(marker) => ( -
-
- - { - const m = marker(); - if (m.type === "single") return m.value; - })()} - > - {(markerValue) => { - const value = createMemo(() => { - const m = markerValue(); - return m.type === "time" ? m.time : 0; - }); - - return ( -
- { - setProject( - "timeline", - "segments", - produce((s) => { - if (markerValue().type === "reset") { - s[i() - 1].end = s[i()].end; - s.splice(i(), 1); - } else { - s[i() - 1].end = s[i()].start; - } - }) - ); - }} - /> -
- ); - }} -
- { - const m = marker(); - if ( - m.type === "dual" && - m.right && - m.right.type === "time" - ) - return m.right; - })()} - > - {(markerValue) => { - const value = createMemo(() => { - const m = markerValue(); - return m.type === "time" ? m.time : 0; - }); - - return ( -
- { - setProject( - "timeline", - "segments", - i(), - "start", - 0 - ); - }} - /> -
- ); - }} -
-
-
- )} - - { - e.stopPropagation(); - - if (editorState.timeline.interactMode === "split") { - const rect = e.currentTarget.getBoundingClientRect(); - const fraction = (e.clientX - rect.left) / rect.width; - - const splitTime = fraction * (segment.end - segment.start); - - projectActions.splitClipSegment(prevDuration() + splitTime); - } else { - createRoot((dispose) => { - createEventListener( - e.currentTarget, - "mouseup", - (upEvent) => { - dispose(); - - const currentIndex = i(); - const selection = editorState.timeline.selection; - const isMac = - navigator.platform.toUpperCase().indexOf("MAC") >= - 0; - const isMultiSelect = isMac - ? upEvent.metaKey - : upEvent.ctrlKey; - const isRangeSelect = upEvent.shiftKey; - - if ( - isRangeSelect && - selection && - selection.type === "clip" - ) { - // Range selection: select from last selected to current - const existingIndices = selection.indices; - const lastIndex = - existingIndices[existingIndices.length - 1]; - const start = Math.min(lastIndex, currentIndex); - const end = Math.max(lastIndex, currentIndex); - const rangeIndices = Array.from( - { length: end - start + 1 }, - (_, idx) => start + idx - ); - - setEditorState("timeline", "selection", { - type: "clip" as const, - indices: rangeIndices, - }); - } else if ( - isMultiSelect && - selection && - selection.type === "clip" - ) { - // Multi-select: toggle current index - const existingIndices = selection.indices; - - if (existingIndices.includes(currentIndex)) { - // Remove from selection - const newIndices = existingIndices.filter( - (idx) => idx !== currentIndex - ); - if (newIndices.length > 0) { - setEditorState("timeline", "selection", { - type: "clip" as const, - indices: newIndices, - }); - } else { - setEditorState("timeline", "selection", null); - } - } else { - // Add to selection - setEditorState("timeline", "selection", { - type: "clip" as const, - indices: [...existingIndices, currentIndex], - }); - } - } else { - // Normal single selection - setEditorState("timeline", "selection", { - type: "clip" as const, - indices: [currentIndex], - }); - } - - props.handleUpdatePlayhead(upEvent); - } - ); - }); - } - }} - > - - - - - { - if (split()) return; - - const initialStart = segment.start; - setStartHandleDrag({ - offset: 0, - initialStart, - }); - - const maxSegmentDuration = - editorInstance.recordings.segments[ - segment.recordingSegment ?? 0 - ].display.duration; - - const availableTimelineDuration = - editorInstance.recordingDuration - - segments().reduce( - (acc, segment, segmentI) => - segmentI === i() - ? acc - : acc + - (segment.end - segment.start) / segment.timescale, - 0 - ); - - const maxDuration = Math.min( - maxSegmentDuration, - availableTimelineDuration - ); - - const prevSegment = segments()[i() - 1]; - const prevSegmentIsSameClip = - prevSegment?.recordingSegment !== undefined - ? prevSegment.recordingSegment === - segment.recordingSegment - : false; - - function update(event: MouseEvent) { - const newStart = - initialStart + - (event.clientX - downEvent.clientX) * - secsPerPixel() * - segment.timescale; - - const clampedStart = Math.min( - Math.max( - newStart, - prevSegmentIsSameClip ? prevSegment.end : 0, - segment.end - maxDuration - ), - segment.end - 1 - ); - - setStartHandleDrag({ - offset: clampedStart - initialStart, - initialStart, - }); - - setProject( - "timeline", - "segments", - i(), - "start", - clampedStart - ); - } - - const resumeHistory = projectHistory.pause(); - createRoot((dispose) => { - onCleanup(() => { - resumeHistory(); - console.log("NUL"); - setStartHandleDrag(null); - onHandleReleased(); - }); - - createEventListenerMap(window, { - mousemove: update, - mouseup: (e) => { - update(e); - dispose(); - }, - blur: () => dispose(), - mouseleave: () => dispose(), - }); - }); - }} - /> - - {(() => { - const ctx = useSegmentContext(); - - return ( - 100}> -
- - {hasMultipleRecordingSegments() - ? `Clip ${segment.recordingSegment}` - : "Clip"} - -
- {" "} - {formatTime(segment.end - segment.start)} -
-
-
- ); - })()} -
- { - const end = segment.end; - - if (split()) return; - const maxSegmentDuration = - editorInstance.recordings.segments[ - segment.recordingSegment ?? 0 - ].display.duration; - - const availableTimelineDuration = - editorInstance.recordingDuration - - segments().reduce( - (acc, segment, segmentI) => - segmentI === i() - ? acc - : acc + - (segment.end - segment.start) / segment.timescale, - 0 - ); - - const nextSegment = segments()[i() + 1]; - const nextSegmentIsSameClip = - nextSegment?.recordingSegment !== undefined - ? nextSegment.recordingSegment === - segment.recordingSegment - : false; - - function update(event: MouseEvent) { - const deltaRecorded = - (event.clientX - downEvent.clientX) * - secsPerPixel() * - segment.timescale; - const newEnd = end + deltaRecorded; - - setProject( - "timeline", - "segments", - i(), - "end", - Math.max( - Math.min( - newEnd, - // availableTimelineDuration is in timeline seconds; convert to recorded seconds - end + availableTimelineDuration * segment.timescale, - nextSegmentIsSameClip - ? nextSegment.start - : maxSegmentDuration - ), - segment.start + 1 - ) - ); - } - - const resumeHistory = projectHistory.pause(); - createRoot((dispose) => { - createEventListenerMap(window, { - mousemove: update, - mouseup: (e) => { - dispose(); - resumeHistory(); - update(e); - onHandleReleased(); - }, - blur: () => { - dispose(); - resumeHistory(); - onHandleReleased(); - }, - mouseleave: () => { - dispose(); - resumeHistory(); - onHandleReleased(); - }, - }); - }); - }} - /> -
- { - const m = endMarker(); - if (m?.type === "dual" && m.left && m.left.type === "time") - return m.left; - })()} - > - {(markerValue) => { - const value = createMemo(() => { - const m = markerValue(); - return m.type === "time" ? m.time : 0; - }); - - return ( -
-
-
- { - setProject( - "timeline", - "segments", - i(), - "end", - segmentRecording().display.duration - ); - }} - /> -
-
- ); - }} - - - ); - }} - - - ); + const { + project, + setProject, + projectActions, + editorInstance, + projectHistory, + editorState, + setEditorState, + totalDuration, + micWaveforms, + systemAudioWaveforms, + } = useEditorContext(); + + const { secsPerPixel, duration } = useTimelineContext(); + + const segments = (): Array => + project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; + + function onHandleReleased() { + const { transform } = editorState.timeline; + + if (transform.position + transform.zoom > totalDuration() + 4) { + transform.updateZoom(totalDuration(), editorState.previewTime!); + } + } + + const hasMultipleRecordingSegments = () => + editorInstance.recordings.segments.length > 1; + + const split = () => editorState.timeline.interactMode === "split"; + + return ( + setEditorState("timeline", "hoveredTrack", "clip")} + onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} + > + + {(segment, i) => { + const [startHandleDrag, setStartHandleDrag] = createSignal(null); + + const prefixOffsets = createMemo(() => { + const segs = segments(); + const out: number[] = new Array(segs.length); + let sum = 0; + for (let k = 0; k < segs.length; k++) { + out[k] = sum; + sum += (segs[k].end - segs[k].start) / segs[k].timescale; + } + return out; + }); + const prevDuration = createMemo(() => prefixOffsets()[i()] ?? 0); + + const relativeSegment = createMemo(() => { + const ds = startHandleDrag(); + const offset = ds ? ds.offset / segment.timescale : 0; + + return { + start: Math.max(prevDuration() + offset, 0), + end: + prevDuration() + + offset + + (segment.end - segment.start) / segment.timescale, + timescale: segment.timescale, + recordingSegment: segment.recordingSegment, + }; + }); + + const segmentX = useSegmentTranslateX(relativeSegment); + const segmentWidth = useSegmentWidth(relativeSegment); + + const segmentRecording = (s = i()) => + editorInstance.recordings.segments[ + segments()[s].recordingSegment ?? 0 + ]; + + const marker = useSectionMarker(() => ({ + segments: segments(), + i: i(), + position: "left", + })); + + const endMarker = useSectionMarker(() => ({ + segments: segments(), + i: i(), + position: "right", + })); + + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "clip") return false; + + const segmentIndex = project.timeline?.segments?.findIndex( + (s) => s.start === segment.start && s.end === segment.end, + ); + + if (segmentIndex === undefined || segmentIndex === -1) return false; + + return selection.indices.includes(segmentIndex); + }); + + const micWaveform = () => { + if (project.audio.micVolumeDb && project.audio.micVolumeDb < -30) + return; + + const idx = segment.recordingSegment ?? i(); + return micWaveforms()?.[idx] ?? []; + }; + + const systemAudioWaveform = () => { + if ( + project.audio.systemVolumeDb && + project.audio.systemVolumeDb <= -30 + ) + return; + + const idx = segment.recordingSegment ?? i(); + return systemAudioWaveforms()?.[idx] ?? []; + }; + + return ( + <> + + {(marker) => ( +
+
+ + { + const m = marker(); + if (m.type === "single") return m.value; + })()} + > + {(markerValue) => { + const value = createMemo(() => { + const m = markerValue(); + return m.type === "time" ? m.time : 0; + }); + + return ( +
+ { + setProject( + "timeline", + "segments", + produce((s) => { + if (markerValue().type === "reset") { + s[i() - 1].end = s[i()].end; + s.splice(i(), 1); + } else { + s[i() - 1].end = s[i()].start; + } + }), + ); + }} + /> +
+ ); + }} +
+ { + const m = marker(); + if ( + m.type === "dual" && + m.right && + m.right.type === "time" + ) + return m.right; + })()} + > + {(markerValue) => { + const value = createMemo(() => { + const m = markerValue(); + return m.type === "time" ? m.time : 0; + }); + + return ( +
+ { + setProject( + "timeline", + "segments", + i(), + "start", + 0, + ); + }} + /> +
+ ); + }} +
+
+
+ )} + + { + e.stopPropagation(); + + if (editorState.timeline.interactMode === "split") { + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = (e.clientX - rect.left) / rect.width; + + const splitTime = fraction * (segment.end - segment.start); + + projectActions.splitClipSegment(prevDuration() + splitTime); + } else { + createRoot((dispose) => { + createEventListener( + e.currentTarget, + "mouseup", + (upEvent) => { + dispose(); + + const currentIndex = i(); + const selection = editorState.timeline.selection; + const isMac = + navigator.platform.toUpperCase().indexOf("MAC") >= + 0; + const isMultiSelect = isMac + ? upEvent.metaKey + : upEvent.ctrlKey; + const isRangeSelect = upEvent.shiftKey; + + if ( + isRangeSelect && + selection && + selection.type === "clip" + ) { + // Range selection: select from last selected to current + const existingIndices = selection.indices; + const lastIndex = + existingIndices[existingIndices.length - 1]; + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + const rangeIndices = Array.from( + { length: end - start + 1 }, + (_, idx) => start + idx, + ); + + setEditorState("timeline", "selection", { + type: "clip" as const, + indices: rangeIndices, + }); + } else if ( + isMultiSelect && + selection && + selection.type === "clip" + ) { + // Multi-select: toggle current index + const existingIndices = selection.indices; + + if (existingIndices.includes(currentIndex)) { + // Remove from selection + const newIndices = existingIndices.filter( + (idx) => idx !== currentIndex, + ); + if (newIndices.length > 0) { + setEditorState("timeline", "selection", { + type: "clip" as const, + indices: newIndices, + }); + } else { + setEditorState("timeline", "selection", null); + } + } else { + // Add to selection + setEditorState("timeline", "selection", { + type: "clip" as const, + indices: [...existingIndices, currentIndex], + }); + } + } else { + // Normal single selection + setEditorState("timeline", "selection", { + type: "clip" as const, + indices: [currentIndex], + }); + } + + props.handleUpdatePlayhead(upEvent); + }, + ); + }); + } + }} + > + + + + + { + if (split()) return; + + const initialStart = segment.start; + setStartHandleDrag({ + offset: 0, + initialStart, + }); + + const maxSegmentDuration = + editorInstance.recordings.segments[ + segment.recordingSegment ?? 0 + ].display.duration; + + const availableTimelineDuration = + editorInstance.recordingDuration - + segments().reduce( + (acc, segment, segmentI) => + segmentI === i() + ? acc + : acc + + (segment.end - segment.start) / segment.timescale, + 0, + ); + + const maxDuration = Math.min( + maxSegmentDuration, + availableTimelineDuration, + ); + + const prevSegment = segments()[i() - 1]; + const prevSegmentIsSameClip = + prevSegment?.recordingSegment !== undefined + ? prevSegment.recordingSegment === + segment.recordingSegment + : false; + + function update(event: MouseEvent) { + const newStart = + initialStart + + (event.clientX - downEvent.clientX) * + secsPerPixel() * + segment.timescale; + + const clampedStart = Math.min( + Math.max( + newStart, + prevSegmentIsSameClip ? prevSegment.end : 0, + segment.end - maxDuration, + ), + segment.end - 1, + ); + + setStartHandleDrag({ + offset: clampedStart - initialStart, + initialStart, + }); + + setProject( + "timeline", + "segments", + i(), + "start", + clampedStart, + ); + } + + const resumeHistory = projectHistory.pause(); + createRoot((dispose) => { + onCleanup(() => { + resumeHistory(); + console.log("NUL"); + setStartHandleDrag(null); + onHandleReleased(); + }); + + createEventListenerMap(window, { + mousemove: update, + mouseup: (e) => { + update(e); + dispose(); + }, + blur: () => dispose(), + mouseleave: () => dispose(), + }); + }); + }} + /> + + {(() => { + const ctx = useSegmentContext(); + + return ( + 100}> +
+ + {hasMultipleRecordingSegments() + ? `Clip ${segment.recordingSegment}` + : "Clip"} + +
+ {" "} + {formatTime(segment.end - segment.start)} +
+
+
+ ); + })()} +
+ { + const end = segment.end; + + if (split()) return; + const maxSegmentDuration = + editorInstance.recordings.segments[ + segment.recordingSegment ?? 0 + ].display.duration; + + const availableTimelineDuration = + editorInstance.recordingDuration - + segments().reduce( + (acc, segment, segmentI) => + segmentI === i() + ? acc + : acc + + (segment.end - segment.start) / segment.timescale, + 0, + ); + + const nextSegment = segments()[i() + 1]; + const nextSegmentIsSameClip = + nextSegment?.recordingSegment !== undefined + ? nextSegment.recordingSegment === + segment.recordingSegment + : false; + + function update(event: MouseEvent) { + const deltaRecorded = + (event.clientX - downEvent.clientX) * + secsPerPixel() * + segment.timescale; + const newEnd = end + deltaRecorded; + + setProject( + "timeline", + "segments", + i(), + "end", + Math.max( + Math.min( + newEnd, + // availableTimelineDuration is in timeline seconds; convert to recorded seconds + end + availableTimelineDuration * segment.timescale, + nextSegmentIsSameClip + ? nextSegment.start + : maxSegmentDuration, + ), + segment.start + 1, + ), + ); + } + + const resumeHistory = projectHistory.pause(); + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: update, + mouseup: (e) => { + dispose(); + resumeHistory(); + update(e); + onHandleReleased(); + }, + blur: () => { + dispose(); + resumeHistory(); + onHandleReleased(); + }, + mouseleave: () => { + dispose(); + resumeHistory(); + onHandleReleased(); + }, + }); + }); + }} + /> +
+ { + const m = endMarker(); + if (m?.type === "dual" && m.left && m.left.type === "time") + return m.left; + })()} + > + {(markerValue) => { + const value = createMemo(() => { + const m = markerValue(); + return m.type === "time" ? m.time : 0; + }); + + return ( +
+
+
+ { + setProject( + "timeline", + "segments", + i(), + "end", + segmentRecording().display.duration, + ); + }} + /> +
+
+ ); + }} + + + ); + }} + + + ); } function Markings(props: { segment: TimelineSegment; prevDuration: number }) { - const { editorState } = useEditorContext(); - const { secsPerPixel, markingResolution } = useTimelineContext(); - - const markings = () => { - const resolution = markingResolution(); - - const { transform } = editorState.timeline; - const visibleMin = - transform.position - props.prevDuration + props.segment.start; - const visibleMax = visibleMin + transform.zoom; - - const start = Math.floor(visibleMin / resolution); - - return Array.from( - { length: Math.ceil(visibleMax / resolution) - start }, - (_, i) => (start + i) * resolution - ); - }; - - return ( - - {(marking) => ( -
- )} - - ); + const { editorState } = useEditorContext(); + const { secsPerPixel, markingResolution } = useTimelineContext(); + + const markings = () => { + const resolution = markingResolution(); + + const { transform } = editorState.timeline; + const visibleMin = + transform.position - props.prevDuration + props.segment.start; + const visibleMax = visibleMin + transform.zoom; + + const start = Math.floor(visibleMin / resolution); + + return Array.from( + { length: Math.ceil(visibleMax / resolution) - start }, + (_, i) => (start + i) * resolution, + ); + }; + + return ( + + {(marking) => ( +
+ )} + + ); } function CutOffsetButton(props: { - value: number; - class?: string; - onClick?(): void; + value: number; + class?: string; + onClick?(): void; }) { - return ( - - ); + return ( + + ); } function useSectionMarker( - props: () => { - segments: TimelineSegment[]; - i: number; - position: "left" | "right"; - } + props: () => { + segments: TimelineSegment[]; + i: number; + position: "left" | "right"; + }, ) { - const { editorInstance } = useEditorContext(); + const { editorInstance } = useEditorContext(); - return () => getSectionMarker(props(), editorInstance.recordings.segments); + return () => getSectionMarker(props(), editorInstance.recordings.segments); } diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index d75c508dff..347714fc84 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -1,28 +1,28 @@ "use client"; import { buildEnv } from "@cap/env"; import { - Avatar, - Button, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - Popover, - PopoverContent, - PopoverTrigger, + Avatar, + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Popover, + PopoverContent, + PopoverTrigger, } from "@cap/ui"; import { classNames } from "@cap/utils"; import { - faBuilding, - faCircleInfo, - faLink, + faBuilding, + faCircleInfo, + faLink, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; @@ -43,411 +43,411 @@ import SpacesList from "./SpacesList"; import { updateActiveOrganization } from "./server"; interface Props { - toggleMobileNav?: () => void; + toggleMobileNav?: () => void; } const AdminNavItems = ({ toggleMobileNav }: Props) => { - const pathname = usePathname(); - const [open, setOpen] = useState(false); - const { user, sidebarCollapsed, userCapsCount } = useDashboardContext(); + const pathname = usePathname(); + const [open, setOpen] = useState(false); + const { user, sidebarCollapsed, userCapsCount } = useDashboardContext(); - const manageNavigation = [ - { - name: "My Caps", - href: `/dashboard/caps`, - extraText: userCapsCount, - icon: , - subNav: [], - }, - { - name: "Organization Settings", - href: `/dashboard/settings/organization`, - ownerOnly: true, - icon: , - subNav: [], - }, - ]; + const manageNavigation = [ + { + name: "My Caps", + href: `/dashboard/caps`, + extraText: userCapsCount, + icon: , + subNav: [], + }, + { + name: "Organization Settings", + href: `/dashboard/settings/organization`, + ownerOnly: true, + icon: , + subNav: [], + }, + ]; - const [dialogOpen, setDialogOpen] = useState(false); - const { organizationData: orgData, activeOrganization: activeOrg } = - useDashboardContext(); - const formRef = useRef(null); - const [createLoading, setCreateLoading] = useState(false); - const [organizationName, setOrganizationName] = useState(""); - const isOwner = activeOrg?.organization.ownerId === user.id; - const [openAIDialog, setOpenAIDialog] = useState(false); - const router = useRouter(); + const [dialogOpen, setDialogOpen] = useState(false); + const { organizationData: orgData, activeOrganization: activeOrg } = + useDashboardContext(); + const formRef = useRef(null); + const [createLoading, setCreateLoading] = useState(false); + const [organizationName, setOrganizationName] = useState(""); + const isOwner = activeOrg?.organization.ownerId === user.id; + const [openAIDialog, setOpenAIDialog] = useState(false); + const router = useRouter(); - const isPathActive = (path: string) => pathname.includes(path); - const isDomainSetupVerified = - activeOrg?.organization.customDomain && - activeOrg?.organization.domainVerified; + const isPathActive = (path: string) => pathname.includes(path); + const isDomainSetupVerified = + activeOrg?.organization.customDomain && + activeOrg?.organization.domainVerified; - return ( - - - - - -
-
-
- -
-
-
- {!sidebarCollapsed && ( -

- {activeOrg?.organization.name ?? - "No organization found"} -

- )} - {!sidebarCollapsed && ( - - )} -
- {!sidebarCollapsed && ( - - -

- {isDomainSetupVerified - ? activeOrg?.organization.customDomain - : "No custom domain set"} -

- - )} -
-
-
- - - - No organizations found - - {orgData?.map((organization) => { - const isSelected = - activeOrg?.organization.id === - organization.organization.id; - return ( - { - await updateActiveOrganization( - organization.organization.id - ); - setOpen(false); - router.push("/dashboard/caps"); - }} - > -
- -

- {organization.organization.name} -

-
- {isSelected && ( - - )} -
- ); - })} - - - -
-
-
-
-
-
-
- - - } - description="A new organization to share caps with your team" - > - - Create New Organization - - -
- setDialogOpen(false)} - formRef={formRef} - onNameChange={setOrganizationName} - /> -
- - - - -
-
- ); + toggleMobileNav?.()} /> +
+
+ + {!sidebarCollapsed && !user.isPro && ( + + + + )} + + toggleMobileNav?.()} + subscribed={user.isPro} + /> + {buildEnv.NEXT_PUBLIC_IS_CAP && ( +
+ + Earn 40% Referral + +
+ )} +

+ Cap Software, Inc. {new Date().getFullYear()}. +

+
+ + + } + description="A new organization to share caps with your team" + > + + Create New Organization + + +
+ setDialogOpen(false)} + formRef={formRef} + onNameChange={setOrganizationName} + /> +
+ + + + +
+ + ); }; const NavItem = ({ - name, - href, - icon, - sidebarCollapsed, - toggleMobileNav, - isPathActive, - extraText, + name, + href, + icon, + sidebarCollapsed, + toggleMobileNav, + isPathActive, + extraText, }: { - name: string; - href: string; - icon: React.ReactElement<{ - ref: RefObject; - className: string; - size: number; - }>; - sidebarCollapsed: boolean; - toggleMobileNav?: () => void; - isPathActive: (path: string) => boolean; - extraText: number | null | undefined; + name: string; + href: string; + icon: React.ReactElement<{ + ref: RefObject; + className: string; + size: number; + }>; + sidebarCollapsed: boolean; + toggleMobileNav?: () => void; + isPathActive: (path: string) => boolean; + extraText: number | null | undefined; }) => { - const iconRef = useRef(null); - return ( - - toggleMobileNav?.()} - onMouseEnter={() => { - iconRef.current?.startAnimation(); - }} - onMouseLeave={() => { - iconRef.current?.stopAnimation(); - }} - prefetch={false} - passHref - className={classNames( - "relative border border-transparent transition z-3", - sidebarCollapsed - ? "flex justify-center items-center px-0 w-full size-9" - : "px-3 py-2 w-full", - isPathActive(href) - ? "bg-transparent pointer-events-none" - : "hover:bg-gray-2", - "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none" - )} - > - {cloneElement(icon, { - ref: iconRef, - className: clsx( - sidebarCollapsed ? "text-gray-12 mx-auto" : "text-gray-10" - ), - size: sidebarCollapsed ? 18 : 16, - })} -

- {name} -

- {extraText !== null && !sidebarCollapsed && ( -

- {extraText} -

- )} - -
- ); + const iconRef = useRef(null); + return ( + + toggleMobileNav?.()} + onMouseEnter={() => { + iconRef.current?.startAnimation(); + }} + onMouseLeave={() => { + iconRef.current?.stopAnimation(); + }} + prefetch={false} + passHref + className={classNames( + "relative border border-transparent transition z-3", + sidebarCollapsed + ? "flex justify-center items-center px-0 w-full size-9" + : "px-3 py-2 w-full", + isPathActive(href) + ? "bg-transparent pointer-events-none" + : "hover:bg-gray-2", + "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none", + )} + > + {cloneElement(icon, { + ref: iconRef, + className: clsx( + sidebarCollapsed ? "text-gray-12 mx-auto" : "text-gray-10", + ), + size: sidebarCollapsed ? 18 : 16, + })} +

+ {name} +

+ {extraText !== null && !sidebarCollapsed && ( +

+ {extraText} +

+ )} + +
+ ); }; export default AdminNavItems; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx index c0b7b72702..b8bf47b06b 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraPreviewWindow.tsx @@ -1,554 +1,558 @@ "use client"; +import { LoadingSpinner } from "@cap/ui"; +import clsx from "clsx"; import { - X, - Maximize2, - Circle, - Square, - RectangleHorizontal, - FlipHorizontal, - PictureInPicture, + Circle, + FlipHorizontal, + Maximize2, + PictureInPicture, + RectangleHorizontal, + Square, + X, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import clsx from "clsx"; -import { LoadingSpinner } from "@cap/ui"; type CameraPreviewSize = "sm" | "lg"; type CameraPreviewShape = "round" | "square" | "full"; type VideoDimensions = { - width: number; - height: number; + width: number; + height: number; }; const WINDOW_PADDING = 20; const BAR_HEIGHT = 52; const getPreviewMetrics = ( - previewSize: CameraPreviewSize, - previewShape: CameraPreviewShape, - dimensions: VideoDimensions | null + previewSize: CameraPreviewSize, + previewShape: CameraPreviewShape, + dimensions: VideoDimensions | null, ) => { - const base = previewSize === "sm" ? 230 : 400; - - if (!dimensions || dimensions.height === 0) { - return { - base, - width: base, - height: base, - aspectRatio: 1, - }; - } - - const aspectRatio = dimensions.width / dimensions.height; - - if (previewShape !== "full") { - return { - base, - width: base, - height: base, - aspectRatio, - }; - } - - if (aspectRatio >= 1) { - return { - base, - width: base * aspectRatio, - height: base, - aspectRatio, - }; - } - - return { - base, - width: base, - height: base / aspectRatio, - aspectRatio, - }; + const base = previewSize === "sm" ? 230 : 400; + + if (!dimensions || dimensions.height === 0) { + return { + base, + width: base, + height: base, + aspectRatio: 1, + }; + } + + const aspectRatio = dimensions.width / dimensions.height; + + if (previewShape !== "full") { + return { + base, + width: base, + height: base, + aspectRatio, + }; + } + + if (aspectRatio >= 1) { + return { + base, + width: base * aspectRatio, + height: base, + aspectRatio, + }; + } + + return { + base, + width: base, + height: base / aspectRatio, + aspectRatio, + }; }; interface CameraPreviewWindowProps { - cameraId: string; - onClose: () => void; + cameraId: string; + onClose: () => void; } export const CameraPreviewWindow = ({ - cameraId, - onClose, + cameraId, + onClose, }: CameraPreviewWindowProps) => { - const [size, setSize] = useState("sm"); - const [shape, setShape] = useState("round"); - const [mirrored, setMirrored] = useState(false); - const [position, setPosition] = useState<{ x: number; y: number } | null>( - null - ); - const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - const videoRef = useRef(null); - const streamRef = useRef(null); - const containerRef = useRef(null); - const [videoDimensions, setVideoDimensions] = - useState(null); - const [mounted, setMounted] = useState(false); - const [isInPictureInPicture, setIsInPictureInPicture] = useState(false); - const autoPictureInPictureRef = useRef(false); - const isPictureInPictureSupported = - typeof document !== "undefined" && document.pictureInPictureEnabled; - - useEffect(() => { - setMounted(true); - return () => { - setMounted(false); - }; - }, []); - - useEffect(() => { - const startCamera = async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { - deviceId: { exact: cameraId }, - }, - }); - - streamRef.current = stream; - - if (videoRef.current) { - videoRef.current.srcObject = stream; - } - } catch (err) { - console.error("Failed to start camera", err); - } - }; - - startCamera(); - - return () => { - if (streamRef.current) { - streamRef.current.getTracks().forEach((track) => { - track.stop(); - }); - streamRef.current = null; - } - }; - }, [cameraId]); - - useEffect(() => { - const metrics = getPreviewMetrics(size, shape, videoDimensions); - - if (typeof window === "undefined") { - return; - } - - const totalHeight = metrics.height + BAR_HEIGHT; - const maxX = Math.max(0, window.innerWidth - metrics.width); - const maxY = Math.max(0, window.innerHeight - totalHeight); - - setPosition((prev) => { - const defaultX = window.innerWidth - metrics.width - WINDOW_PADDING; - const defaultY = window.innerHeight - totalHeight - WINDOW_PADDING; - const nextX = prev?.x ?? defaultX; - const nextY = prev?.y ?? defaultY; - - return { - x: Math.max(0, Math.min(nextX, maxX)), - y: Math.max(0, Math.min(nextY, maxY)), - }; - }); - }, [size, shape, videoDimensions]); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest("[data-controls]")) { - return; - } - e.stopPropagation(); - e.preventDefault(); - setIsDragging(true); - setDragStart({ - x: e.clientX - (position?.x || 0), - y: e.clientY - (position?.y || 0), - }); - }, - [position] - ); - - const handleMouseMove = useCallback( - (e: MouseEvent) => { - if (!isDragging) return; - - const newX = e.clientX - dragStart.x; - const newY = e.clientY - dragStart.y; - - const metrics = getPreviewMetrics(size, shape, videoDimensions); - const totalHeight = metrics.height + BAR_HEIGHT; - const maxX = Math.max(0, window.innerWidth - metrics.width); - const maxY = Math.max(0, window.innerHeight - totalHeight); - - setPosition({ - x: Math.max(0, Math.min(newX, maxX)), - y: Math.max(0, Math.min(newY, maxY)), - }); - }, - [isDragging, dragStart, size, shape, videoDimensions] - ); - - const handleMouseUp = useCallback(() => { - setIsDragging(false); - }, []); - - useEffect(() => { - if (isDragging) { - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - } - }, [isDragging, handleMouseMove, handleMouseUp]); - - const handleClose = useCallback(async () => { - if ( - videoRef.current && - document.pictureInPictureElement === videoRef.current - ) { - try { - autoPictureInPictureRef.current = false; - await document.exitPictureInPicture(); - } catch (err) { - console.error("Failed to exit Picture-in-Picture", err); - } - } - onClose(); - }, [onClose]); - - const handleTogglePictureInPicture = useCallback(async () => { - const video = videoRef.current; - if (!video || !isPictureInPictureSupported) return; - - try { - autoPictureInPictureRef.current = false; - if (document.pictureInPictureElement === video) { - await document.exitPictureInPicture(); - } else { - await video.requestPictureInPicture(); - } - } catch (err) { - console.error("Failed to toggle Picture-in-Picture", err); - } - }, [isPictureInPictureSupported]); - - useEffect(() => { - if (!videoRef.current || !videoDimensions || !isPictureInPictureSupported) - return; - - const video = videoRef.current; - - const handlePipEnter = () => { - setIsInPictureInPicture(true); - }; - - const handlePipLeave = () => { - autoPictureInPictureRef.current = false; - setIsInPictureInPicture(false); - }; - - video.addEventListener("enterpictureinpicture", handlePipEnter); - video.addEventListener("leavepictureinpicture", handlePipLeave); - - if (document.pictureInPictureElement === video) { - setIsInPictureInPicture(true); - } - - return () => { - video.removeEventListener("enterpictureinpicture", handlePipEnter); - video.removeEventListener("leavepictureinpicture", handlePipLeave); - }; - }, [videoDimensions, isPictureInPictureSupported]); - - useEffect(() => { - if (typeof document === "undefined") { - return; - } - - if (!isPictureInPictureSupported) { - return; - } - - const handleVisibilityChange = () => { - const video = videoRef.current; - - if (!video || !videoDimensions) { - return; - } - - const currentElement = document.pictureInPictureElement; - - if ( - currentElement && - currentElement !== video && - document.visibilityState === "hidden" - ) { - return; - } - - if (document.visibilityState === "hidden") { - if (currentElement === video) { - return; - } - - video - .requestPictureInPicture() - .then(() => { - autoPictureInPictureRef.current = true; - }) - .catch((err) => { - autoPictureInPictureRef.current = false; - console.error( - "Failed to enter Picture-in-Picture on tab change", - err - ); - }); - - return; - } - - if ( - autoPictureInPictureRef.current && - currentElement === video && - document.visibilityState === "visible" - ) { - document - .exitPictureInPicture() - .catch((err) => { - console.error( - "Failed to exit Picture-in-Picture after returning", - err - ); - }) - .finally(() => { - autoPictureInPictureRef.current = false; - }); - return; - } - - autoPictureInPictureRef.current = false; - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, [videoDimensions, isPictureInPictureSupported]); - - useEffect(() => { - return () => { - if ( - typeof document !== "undefined" && - videoRef.current && - document.pictureInPictureElement === videoRef.current - ) { - document.exitPictureInPicture().catch((err) => { - console.error("Failed to exit Picture-in-Picture on cleanup", err); - }); - } - }; - }, []); - - if (!mounted || !position) { - return null; - } - - const metrics = getPreviewMetrics(size, shape, videoDimensions); - const totalHeight = metrics.height + BAR_HEIGHT; - - const borderRadius = - shape === "round" ? "9999px" : size === "sm" ? "3rem" : "4rem"; - - return createPortal( -
{ - e.stopPropagation(); - e.preventDefault(); - handleMouseDown(e); - }} - > -
-
-
-
e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.stopPropagation(); - handleClose(); - } - }} - > - - - - - {isPictureInPictureSupported && ( - - )} -
-
-
- -
-
-
-
, - document.body - ); + const [size, setSize] = useState("sm"); + const [shape, setShape] = useState("round"); + const [mirrored, setMirrored] = useState(false); + const [position, setPosition] = useState<{ x: number; y: number } | null>( + null, + ); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const videoRef = useRef(null); + const streamRef = useRef(null); + const containerRef = useRef(null); + const [videoDimensions, setVideoDimensions] = + useState(null); + const [mounted, setMounted] = useState(false); + const [isInPictureInPicture, setIsInPictureInPicture] = useState(false); + const autoPictureInPictureRef = useRef(false); + const isPictureInPictureSupported = + typeof document !== "undefined" && document.pictureInPictureEnabled; + + useEffect(() => { + setMounted(true); + return () => { + setMounted(false); + }; + }, []); + + useEffect(() => { + const startCamera = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + deviceId: { exact: cameraId }, + }, + }); + + streamRef.current = stream; + + if (videoRef.current) { + videoRef.current.srcObject = stream; + } + } catch (err) { + console.error("Failed to start camera", err); + } + }; + + startCamera(); + + return () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => { + track.stop(); + }); + streamRef.current = null; + } + }; + }, [cameraId]); + + useEffect(() => { + const metrics = getPreviewMetrics(size, shape, videoDimensions); + + if (typeof window === "undefined") { + return; + } + + const totalHeight = metrics.height + BAR_HEIGHT; + const maxX = Math.max(0, window.innerWidth - metrics.width); + const maxY = Math.max(0, window.innerHeight - totalHeight); + + setPosition((prev) => { + const defaultX = window.innerWidth - metrics.width - WINDOW_PADDING; + const defaultY = window.innerHeight - totalHeight - WINDOW_PADDING; + const nextX = prev?.x ?? defaultX; + const nextY = prev?.y ?? defaultY; + + return { + x: Math.max(0, Math.min(nextX, maxX)), + y: Math.max(0, Math.min(nextY, maxY)), + }; + }); + }, [size, shape, videoDimensions]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("[data-controls]")) { + return; + } + e.stopPropagation(); + e.preventDefault(); + setIsDragging(true); + setDragStart({ + x: e.clientX - (position?.x || 0), + y: e.clientY - (position?.y || 0), + }); + }, + [position], + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + + const newX = e.clientX - dragStart.x; + const newY = e.clientY - dragStart.y; + + const metrics = getPreviewMetrics(size, shape, videoDimensions); + const totalHeight = metrics.height + BAR_HEIGHT; + const maxX = Math.max(0, window.innerWidth - metrics.width); + const maxY = Math.max(0, window.innerHeight - totalHeight); + + setPosition({ + x: Math.max(0, Math.min(newX, maxX)), + y: Math.max(0, Math.min(newY, maxY)), + }); + }, + [isDragging, dragStart, size, shape, videoDimensions], + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + const handleClose = useCallback(async () => { + if ( + videoRef.current && + document.pictureInPictureElement === videoRef.current + ) { + try { + autoPictureInPictureRef.current = false; + await document.exitPictureInPicture(); + } catch (err) { + console.error("Failed to exit Picture-in-Picture", err); + } + } + onClose(); + }, [onClose]); + + const handleTogglePictureInPicture = useCallback(async () => { + const video = videoRef.current; + if (!video || !isPictureInPictureSupported) return; + + try { + autoPictureInPictureRef.current = false; + if (document.pictureInPictureElement === video) { + await document.exitPictureInPicture(); + } else { + await video.requestPictureInPicture(); + } + } catch (err) { + console.error("Failed to toggle Picture-in-Picture", err); + } + }, [isPictureInPictureSupported]); + + useEffect(() => { + if (!videoRef.current || !videoDimensions || !isPictureInPictureSupported) + return; + + const video = videoRef.current; + + const handlePipEnter = () => { + setIsInPictureInPicture(true); + }; + + const handlePipLeave = () => { + autoPictureInPictureRef.current = false; + setIsInPictureInPicture(false); + }; + + video.addEventListener("enterpictureinpicture", handlePipEnter); + video.addEventListener("leavepictureinpicture", handlePipLeave); + + if (document.pictureInPictureElement === video) { + setIsInPictureInPicture(true); + } + + return () => { + video.removeEventListener("enterpictureinpicture", handlePipEnter); + video.removeEventListener("leavepictureinpicture", handlePipLeave); + }; + }, [videoDimensions, isPictureInPictureSupported]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + if (!isPictureInPictureSupported) { + return; + } + + const handleVisibilityChange = () => { + const video = videoRef.current; + + if (!video || !videoDimensions) { + return; + } + + const currentElement = document.pictureInPictureElement; + + if ( + currentElement && + currentElement !== video && + document.visibilityState === "hidden" + ) { + return; + } + + if (document.visibilityState === "hidden") { + if (currentElement === video) { + return; + } + + video + .requestPictureInPicture() + .then(() => { + autoPictureInPictureRef.current = true; + }) + .catch((err) => { + autoPictureInPictureRef.current = false; + console.error( + "Failed to enter Picture-in-Picture on tab change", + err, + ); + }); + + return; + } + + if ( + autoPictureInPictureRef.current && + currentElement === video && + document.visibilityState === "visible" + ) { + document + .exitPictureInPicture() + .catch((err) => { + console.error( + "Failed to exit Picture-in-Picture after returning", + err, + ); + }) + .finally(() => { + autoPictureInPictureRef.current = false; + }); + return; + } + + autoPictureInPictureRef.current = false; + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [videoDimensions, isPictureInPictureSupported]); + + useEffect(() => { + return () => { + if ( + typeof document !== "undefined" && + videoRef.current && + document.pictureInPictureElement === videoRef.current + ) { + document.exitPictureInPicture().catch((err) => { + console.error("Failed to exit Picture-in-Picture on cleanup", err); + }); + } + }; + }, []); + + if (!mounted || !position) { + return null; + } + + const metrics = getPreviewMetrics(size, shape, videoDimensions); + const totalHeight = metrics.height + BAR_HEIGHT; + + const borderRadius = + shape === "round" ? "9999px" : size === "sm" ? "3rem" : "4rem"; + + return createPortal( +
{ + e.stopPropagation(); + e.preventDefault(); + handleMouseDown(e); + }} + > +
+
+
+
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.stopPropagation(); + handleClose(); + } + }} + > + + + + + {isPictureInPictureSupported && ( + + )} +
+
+
+ +
+
+
+
, + document.body, + ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx index eb4157d9c5..1ae4d3dc94 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/CameraSelector.tsx @@ -1,173 +1,174 @@ "use client"; import { - SelectRoot, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + SelectContent, + SelectItem, + SelectRoot, + SelectTrigger, + SelectValue, } from "@cap/ui"; import clsx from "clsx"; import { CameraIcon, CameraOffIcon } from "lucide-react"; +import type { KeyboardEvent, MouseEvent } from "react"; import { toast } from "sonner"; -import { NO_CAMERA, NO_CAMERA_VALUE } from "./web-recorder-constants"; import { useMediaPermission } from "./useMediaPermission"; - -import type { KeyboardEvent, MouseEvent } from "react"; +import { NO_CAMERA, NO_CAMERA_VALUE } from "./web-recorder-constants"; interface CameraSelectorProps { - selectedCameraId: string | null; - availableCameras: MediaDeviceInfo[]; - dialogOpen: boolean; - disabled?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; - onCameraChange: (cameraId: string | null) => void; - onRefreshDevices: () => Promise | void; + selectedCameraId: string | null; + availableCameras: MediaDeviceInfo[]; + dialogOpen: boolean; + disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onCameraChange: (cameraId: string | null) => void; + onRefreshDevices: () => Promise | void; } export const CameraSelector = ({ - selectedCameraId, - availableCameras, - dialogOpen, - disabled = false, - open, - onOpenChange, - onCameraChange, - onRefreshDevices, + selectedCameraId, + availableCameras, + dialogOpen, + disabled = false, + open, + onOpenChange, + onCameraChange, + onRefreshDevices, }: CameraSelectorProps) => { - const cameraEnabled = selectedCameraId !== null; - const { state: permissionState, requestPermission } = useMediaPermission( - "camera", - dialogOpen - ); + const cameraEnabled = selectedCameraId !== null; + const { state: permissionState, requestPermission } = useMediaPermission( + "camera", + dialogOpen, + ); - const permissionSupported = permissionState !== "unsupported"; - const shouldRequestPermission = - permissionSupported && permissionState !== "granted"; + const permissionSupported = permissionState !== "unsupported"; + const shouldRequestPermission = + permissionSupported && permissionState !== "granted"; - const statusPillDisabled = !shouldRequestPermission && !cameraEnabled; + const statusPillDisabled = !shouldRequestPermission && !cameraEnabled; - const statusPillClassName = clsx( - "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]", - statusPillDisabled ? "cursor-default" : "cursor-pointer", - shouldRequestPermission - ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" - : cameraEnabled - ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)] hover:bg-[var(--blue-4)] dark:hover:bg-[var(--blue-5)]" - : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" - ); + const statusPillClassName = clsx( + "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]", + statusPillDisabled ? "cursor-default" : "cursor-pointer", + shouldRequestPermission + ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" + : cameraEnabled + ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)] hover:bg-[var(--blue-4)] dark:hover:bg-[var(--blue-5)]" + : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]", + ); - const handleStatusPillClick = async ( - event: MouseEvent | KeyboardEvent - ) => { - if (shouldRequestPermission) { - event.preventDefault(); - event.stopPropagation(); + const handleStatusPillClick = async ( + event: MouseEvent | KeyboardEvent, + ) => { + if (shouldRequestPermission) { + event.preventDefault(); + event.stopPropagation(); - try { - const granted = await requestPermission(); - if (granted) { - await Promise.resolve(onRefreshDevices()); - } - } catch (error) { - console.error("Camera permission request failed", error); - toast.error("Unable to access your camera. Check browser permissions."); - } + try { + const granted = await requestPermission(); + if (granted) { + await Promise.resolve(onRefreshDevices()); + } + } catch (error) { + console.error("Camera permission request failed", error); + toast.error("Unable to access your camera. Check browser permissions."); + } - return; - } + return; + } - if (!cameraEnabled) { - return; - } + if (!cameraEnabled) { + return; + } - event.preventDefault(); - event.stopPropagation(); + event.preventDefault(); + event.stopPropagation(); - onCameraChange(null); - }; + onCameraChange(null); + }; - const handleStatusPillKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") { - handleStatusPillClick(event); - } - }; + const handleStatusPillKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + handleStatusPillClick(event); + } + }; - return ( -
- { - onCameraChange(value === NO_CAMERA_VALUE ? null : value); - }} - disabled={disabled} - open={open} - onOpenChange={onOpenChange} - > -
- svg]:hidden", - disabled || shouldRequestPermission ? "cursor-default" : undefined - )} - onPointerDown={(event) => { - if (shouldRequestPermission) { - event.preventDefault(); - event.stopPropagation(); - } - }} - onKeyDown={(event: KeyboardEvent) => { - if (shouldRequestPermission) { - const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; - if (keys.includes(event.key)) { - event.preventDefault(); - event.stopPropagation(); - } - } - }} - aria-disabled={disabled || shouldRequestPermission} - > - - - -
- - - - - {NO_CAMERA} - - - {availableCameras.map((camera, index) => ( - - - - {camera.label?.trim() || `Camera ${index + 1}`} - - - ))} - -
-
- ); + return ( +
+ { + onCameraChange(value === NO_CAMERA_VALUE ? null : value); + }} + disabled={disabled} + open={open} + onOpenChange={onOpenChange} + > +
+ svg]:hidden", + disabled || shouldRequestPermission + ? "cursor-default" + : undefined, + )} + onPointerDown={(event) => { + if (shouldRequestPermission) { + event.preventDefault(); + event.stopPropagation(); + } + }} + onKeyDown={(event: KeyboardEvent) => { + if (shouldRequestPermission) { + const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; + if (keys.includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } + } + }} + aria-disabled={disabled || shouldRequestPermission} + > + + + +
+ + + + + {NO_CAMERA} + + + {availableCameras.map((camera, index) => ( + + + + {camera.label?.trim() || `Camera ${index + 1}`} + + + ))} + +
+
+ ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx index d1ed825588..c783f63064 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/MicrophoneSelector.tsx @@ -1,175 +1,176 @@ "use client"; import { - SelectRoot, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + SelectContent, + SelectItem, + SelectRoot, + SelectTrigger, + SelectValue, } from "@cap/ui"; import clsx from "clsx"; import { MicIcon, MicOffIcon } from "lucide-react"; +import type { KeyboardEvent, MouseEvent } from "react"; import { toast } from "sonner"; -import { NO_MICROPHONE, NO_MICROPHONE_VALUE } from "./web-recorder-constants"; import { useMediaPermission } from "./useMediaPermission"; - -import type { KeyboardEvent, MouseEvent } from "react"; +import { NO_MICROPHONE, NO_MICROPHONE_VALUE } from "./web-recorder-constants"; interface MicrophoneSelectorProps { - selectedMicId: string | null; - availableMics: MediaDeviceInfo[]; - dialogOpen: boolean; - disabled?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; - onMicChange: (micId: string | null) => void; - onRefreshDevices: () => Promise | void; + selectedMicId: string | null; + availableMics: MediaDeviceInfo[]; + dialogOpen: boolean; + disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onMicChange: (micId: string | null) => void; + onRefreshDevices: () => Promise | void; } export const MicrophoneSelector = ({ - selectedMicId, - availableMics, - dialogOpen, - disabled = false, - open, - onOpenChange, - onMicChange, - onRefreshDevices, + selectedMicId, + availableMics, + dialogOpen, + disabled = false, + open, + onOpenChange, + onMicChange, + onRefreshDevices, }: MicrophoneSelectorProps) => { - const micEnabled = selectedMicId !== null; - const { state: permissionState, requestPermission } = useMediaPermission( - "microphone", - dialogOpen - ); + const micEnabled = selectedMicId !== null; + const { state: permissionState, requestPermission } = useMediaPermission( + "microphone", + dialogOpen, + ); - const permissionSupported = permissionState !== "unsupported"; - const shouldRequestPermission = - permissionSupported && permissionState !== "granted"; + const permissionSupported = permissionState !== "unsupported"; + const shouldRequestPermission = + permissionSupported && permissionState !== "granted"; - const statusPillDisabled = !shouldRequestPermission && !micEnabled; + const statusPillDisabled = !shouldRequestPermission && !micEnabled; - const statusPillClassName = clsx( - "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]", - statusPillDisabled ? "cursor-default" : "cursor-pointer", - shouldRequestPermission - ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" - : micEnabled - ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)] hover:bg-[var(--blue-4)] dark:hover:bg-[var(--blue-5)]" - : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" - ); + const statusPillClassName = clsx( + "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]", + statusPillDisabled ? "cursor-default" : "cursor-pointer", + shouldRequestPermission + ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]" + : micEnabled + ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)] hover:bg-[var(--blue-4)] dark:hover:bg-[var(--blue-5)]" + : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]", + ); - const handleStatusPillClick = async ( - event: MouseEvent | KeyboardEvent - ) => { - if (shouldRequestPermission) { - event.preventDefault(); - event.stopPropagation(); + const handleStatusPillClick = async ( + event: MouseEvent | KeyboardEvent, + ) => { + if (shouldRequestPermission) { + event.preventDefault(); + event.stopPropagation(); - try { - const granted = await requestPermission(); - if (granted) { - await Promise.resolve(onRefreshDevices()); - } - } catch (error) { - console.error("Microphone permission request failed", error); - toast.error( - "Unable to access your microphone. Check browser permissions." - ); - } + try { + const granted = await requestPermission(); + if (granted) { + await Promise.resolve(onRefreshDevices()); + } + } catch (error) { + console.error("Microphone permission request failed", error); + toast.error( + "Unable to access your microphone. Check browser permissions.", + ); + } - return; - } + return; + } - if (!micEnabled) { - return; - } + if (!micEnabled) { + return; + } - event.preventDefault(); - event.stopPropagation(); + event.preventDefault(); + event.stopPropagation(); - onMicChange(null); - }; + onMicChange(null); + }; - const handleStatusPillKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") { - handleStatusPillClick(event); - } - }; + const handleStatusPillKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + handleStatusPillClick(event); + } + }; - return ( -
- { - onMicChange(value === NO_MICROPHONE_VALUE ? null : value); - }} - disabled={disabled} - open={open} - onOpenChange={onOpenChange} - > -
- svg]:hidden", - disabled || shouldRequestPermission ? "cursor-default" : undefined - )} - onPointerDown={(event) => { - if (shouldRequestPermission) { - event.preventDefault(); - event.stopPropagation(); - } - }} - onKeyDown={(event: KeyboardEvent) => { - if (shouldRequestPermission) { - const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; - if (keys.includes(event.key)) { - event.preventDefault(); - event.stopPropagation(); - } - } - }} - aria-disabled={disabled || shouldRequestPermission} - > - - - -
- - - - - {NO_MICROPHONE} - - - {availableMics.map((mic, index) => ( - - - - {mic.label?.trim() || `Microphone ${index + 1}`} - - - ))} - -
-
- ); + return ( +
+ { + onMicChange(value === NO_MICROPHONE_VALUE ? null : value); + }} + disabled={disabled} + open={open} + onOpenChange={onOpenChange} + > +
+ svg]:hidden", + disabled || shouldRequestPermission + ? "cursor-default" + : undefined, + )} + onPointerDown={(event) => { + if (shouldRequestPermission) { + event.preventDefault(); + event.stopPropagation(); + } + }} + onKeyDown={(event: KeyboardEvent) => { + if (shouldRequestPermission) { + const keys = ["Enter", " ", "ArrowDown", "ArrowUp"]; + if (keys.includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } + } + }} + aria-disabled={disabled || shouldRequestPermission} + > + + + +
+ + + + + {NO_MICROPHONE} + + + {availableMics.map((mic, index) => ( + + + + {mic.label?.trim() || `Microphone ${index + 1}`} + + + ))} + +
+
+ ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx index 8d8424c463..02420e4484 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingButton.tsx @@ -4,53 +4,53 @@ import { Button } from "@cap/ui"; import type { SVGProps } from "react"; interface RecordingButtonProps { - isRecording: boolean; - disabled?: boolean; - onStart: () => void; - onStop: () => void; + isRecording: boolean; + disabled?: boolean; + onStart: () => void; + onStop: () => void; } const InstantIcon = ({ className, ...props }: SVGProps) => ( - - - + + + ); export const RecordingButton = ({ - isRecording, - disabled = false, - onStart, - onStop, + isRecording, + disabled = false, + onStart, + onStop, }: RecordingButtonProps) => { - return ( -
- -
- ); + return ( +
+ +
+ ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx index 6808c01138..acbf7f9844 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/RecordingModeSelector.tsx @@ -1,111 +1,112 @@ "use client"; import { - SelectRoot, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + SelectContent, + SelectItem, + SelectRoot, + SelectTrigger, + SelectValue, } from "@cap/ui"; import { - CameraIcon, - Globe, - MonitorIcon, - RectangleHorizontal, - type LucideIcon, + CameraIcon, + Globe, + type LucideIcon, + MonitorIcon, + RectangleHorizontal, } from "lucide-react"; export type RecordingMode = "fullscreen" | "window" | "tab" | "camera"; interface RecordingModeSelectorProps { - mode: RecordingMode; - disabled?: boolean; - onModeChange: (mode: RecordingMode) => void; + mode: RecordingMode; + disabled?: boolean; + onModeChange: (mode: RecordingMode) => void; } export const RecordingModeSelector = ({ - mode, - disabled = false, - onModeChange, + mode, + disabled = false, + onModeChange, }: RecordingModeSelectorProps) => { - const recordingModeOptions: Record< - RecordingMode, - { - label: string; - displayLabel: string; - icon: LucideIcon; - } - > = { - fullscreen: { - label: "Full Screen (Recommended)", - displayLabel: "Full Screen", - icon: MonitorIcon, - }, - window: { - label: "Window", - displayLabel: "Window", - icon: RectangleHorizontal, - }, - tab: { - label: "Current tab", - displayLabel: "Current tab", - icon: Globe, - }, - camera: { - label: "Camera only", - displayLabel: "Camera only", - icon: CameraIcon, - }, - }; + const recordingModeOptions: Record< + RecordingMode, + { + label: string; + displayLabel: string; + icon: LucideIcon; + } + > = { + fullscreen: { + label: "Full Screen (Recommended)", + displayLabel: "Full Screen", + icon: MonitorIcon, + }, + window: { + label: "Window", + displayLabel: "Window", + icon: RectangleHorizontal, + }, + tab: { + label: "Current tab", + displayLabel: "Current tab", + icon: Globe, + }, + camera: { + label: "Camera only", + displayLabel: "Camera only", + icon: CameraIcon, + }, + }; - const selectedOption = mode ? recordingModeOptions[mode] : null; - const SelectedIcon = selectedOption?.icon; + const selectedOption = mode ? recordingModeOptions[mode] : null; + const SelectedIcon = selectedOption?.icon; - return ( -
- { - onModeChange(value as RecordingMode); - }} - disabled={disabled} - > - - - {selectedOption && SelectedIcon && ( - - - {selectedOption.displayLabel} - - )} - - - - {Object.entries(recordingModeOptions).map(([value, option]) => { - const OptionIcon = option.icon; - const isFullscreen = value === "fullscreen"; + return ( +
+ { + onModeChange(value as RecordingMode); + }} + disabled={disabled} + > + + + {selectedOption && SelectedIcon && ( + + + {selectedOption.displayLabel} + + )} + + + + {Object.entries(recordingModeOptions).map(([value, option]) => { + const OptionIcon = option.icon; + const isFullscreen = value === "fullscreen"; - return ( - - - - - {option.label} - - {isFullscreen && ( - - Recommended to capture camera window when picture in picture is activated - - )} - - - ); - })} - - -
- ); + return ( + + + + + {option.label} + + {isFullscreen && ( + + Recommended to capture camera window when picture in + picture is activated + + )} + + + ); + })} +
+
+
+ ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx index 5c67ea2086..c80a4bc901 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx @@ -1,588 +1,588 @@ "use client"; import { - Button, - Dialog, - DialogContent, - DialogTitle, - DialogTrigger, - Switch, + Button, + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, + Switch, } from "@cap/ui"; import { AnimatePresence, motion } from "framer-motion"; import { - ArrowLeftIcon, - CircleHelpIcon, - CloudUploadIcon, - LinkIcon, - MonitorIcon, - PictureInPictureIcon, + ArrowLeftIcon, + CircleHelpIcon, + CloudUploadIcon, + LinkIcon, + MonitorIcon, + PictureInPictureIcon, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import CogIcon from "@/app/(org)/dashboard/_components/AnimatedIcons/Cog"; import { useDashboardContext } from "../../../Contexts"; -import { CameraSelector } from "./CameraSelector"; import { CameraPreviewWindow } from "./CameraPreviewWindow"; +import { CameraSelector } from "./CameraSelector"; import { MicrophoneSelector } from "./MicrophoneSelector"; import { RecordingButton } from "./RecordingButton"; import { - RecordingModeSelector, - type RecordingMode, + type RecordingMode, + RecordingModeSelector, } from "./RecordingModeSelector"; -import { WebRecorderDialogHeader } from "./WebRecorderDialogHeader"; -import { dialogVariants } from "./web-recorder-constants"; import { useCameraDevices } from "./useCameraDevices"; import { useMicrophoneDevices } from "./useMicrophoneDevices"; import { useWebRecorder } from "./useWebRecorder"; +import { WebRecorderDialogHeader } from "./WebRecorderDialogHeader"; +import { dialogVariants } from "./web-recorder-constants"; const REMEMBER_DEVICES_KEY = "cap-web-recorder-remember-devices"; const PREFERRED_CAMERA_KEY = "cap-web-recorder-preferred-camera"; const PREFERRED_MICROPHONE_KEY = "cap-web-recorder-preferred-microphone"; const HOW_IT_WORKS_ITEMS = [ - { - title: "Uploads while you record", - description: - "On compatible browsers, your capture uploads in the background while you record. Otherwise, it records first and uploads right after you stop.", - Icon: CloudUploadIcon, - accent: "bg-blue-3 text-blue-11 dark:bg-blue-4 dark:text-blue-10", - }, - { - title: "Instant shareable link", - description: - "Stopping the recording finalizes the upload immediately so you can copy your link right away.", - Icon: LinkIcon, - accent: "bg-green-3 text-green-11 dark:bg-green-4 dark:text-green-10", - }, - { - title: "Keep your webcam visible", - description: - "On compatible browsers, selecting a camera opens a picture‑in‑picture window that’s captured when you record fullscreen. We recommend fullscreen to keep it on top. If PiP capture isn’t supported, your camera is limited to the Cap recorder page.", - Icon: PictureInPictureIcon, - accent: "bg-purple-3 text-purple-11 dark:bg-purple-4 dark:text-purple-10", - }, + { + title: "Uploads while you record", + description: + "On compatible browsers, your capture uploads in the background while you record. Otherwise, it records first and uploads right after you stop.", + Icon: CloudUploadIcon, + accent: "bg-blue-3 text-blue-11 dark:bg-blue-4 dark:text-blue-10", + }, + { + title: "Instant shareable link", + description: + "Stopping the recording finalizes the upload immediately so you can copy your link right away.", + Icon: LinkIcon, + accent: "bg-green-3 text-green-11 dark:bg-green-4 dark:text-green-10", + }, + { + title: "Keep your webcam visible", + description: + "On compatible browsers, selecting a camera opens a picture‑in‑picture window that’s captured when you record fullscreen. We recommend fullscreen to keep it on top. If PiP capture isn’t supported, your camera is limited to the Cap recorder page.", + Icon: PictureInPictureIcon, + accent: "bg-purple-3 text-purple-11 dark:bg-purple-4 dark:text-purple-10", + }, ] as const; export const WebRecorderDialog = () => { - const [open, setOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - const [howItWorksOpen, setHowItWorksOpen] = useState(false); - const [selectedMicId, setSelectedMicId] = useState(null); - const [recordingMode, setRecordingMode] = - useState("fullscreen"); - const [selectedCameraId, setSelectedCameraId] = useState(null); - const [cameraSelectOpen, setCameraSelectOpen] = useState(false); - const [micSelectOpen, setMicSelectOpen] = useState(false); - const [rememberDevices, setRememberDevices] = useState(false); - const dialogContentRef = useRef(null); - - const { activeOrganization } = useDashboardContext(); - const organisationId = activeOrganization?.organization.id; - const { devices: availableMics, refresh: refreshMics } = - useMicrophoneDevices(open); - const { devices: availableCameras, refresh: refreshCameras } = - useCameraDevices(open); - - const micEnabled = selectedMicId !== null; - - useEffect(() => { - if ( - recordingMode === "camera" && - !selectedCameraId && - availableCameras.length > 0 - ) { - setSelectedCameraId(availableCameras[0]?.deviceId ?? null); - } - }, [recordingMode, selectedCameraId, availableCameras]); - - useEffect(() => { - if (typeof window === "undefined") return; - - try { - const storedRemember = window.localStorage.getItem(REMEMBER_DEVICES_KEY); - if (storedRemember === "true") { - setRememberDevices(true); - } - } catch (error) { - console.error("Failed to load recorder preferences", error); - } - }, []); - - useEffect(() => { - if (!open || !rememberDevices) return; - if (typeof window === "undefined") return; - - try { - const storedCameraId = window.localStorage.getItem(PREFERRED_CAMERA_KEY); - if (storedCameraId) { - const hasCamera = availableCameras.some( - (camera) => camera.deviceId === storedCameraId - ); - if (hasCamera && storedCameraId !== selectedCameraId) { - setSelectedCameraId(storedCameraId); - } - } - - const storedMicId = window.localStorage.getItem(PREFERRED_MICROPHONE_KEY); - if (storedMicId) { - const hasMic = availableMics.some( - (microphone) => microphone.deviceId === storedMicId - ); - if (hasMic && storedMicId !== selectedMicId) { - setSelectedMicId(storedMicId); - } - } - } catch (error) { - console.error("Failed to restore recorder device selection", error); - } - }, [ - open, - rememberDevices, - availableCameras, - availableMics, - selectedCameraId, - selectedMicId, - ]); - - const { - isRecording, - isBusy, - canStartRecording, - startRecording, - stopRecording, - resetState, - } = useWebRecorder({ - organisationId, - selectedMicId, - micEnabled, - recordingMode, - selectedCameraId, - onRecordingSurfaceDetected: (mode) => { - setRecordingMode(mode); - }, - }); - - const handleOpenChange = (next: boolean) => { - if (!next && isBusy) { - toast.info("Keep this dialog open while your upload finishes."); - return; - } - - if (!next) { - resetState(); - setSelectedCameraId(null); - setRecordingMode("fullscreen"); - setSettingsOpen(false); - setHowItWorksOpen(false); - } - setOpen(next); - }; - - const handleStopClick = () => { - stopRecording().catch((err: unknown) => { - console.error("Stop recording error", err); - }); - }; - - const handleClose = () => { - if (!isBusy) { - handleOpenChange(false); - } - }; - - const handleCameraChange = (cameraId: string | null) => { - setSelectedCameraId(cameraId); - - if (!rememberDevices || typeof window === "undefined") { - return; - } - - try { - if (cameraId) { - window.localStorage.setItem(PREFERRED_CAMERA_KEY, cameraId); - } else { - window.localStorage.removeItem(PREFERRED_CAMERA_KEY); - } - } catch (error) { - console.error("Failed to persist preferred camera", error); - } - }; - - const handleMicChange = (micId: string | null) => { - setSelectedMicId(micId); - - if (!rememberDevices || typeof window === "undefined") { - return; - } - - try { - if (micId) { - window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, micId); - } else { - window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); - } - } catch (error) { - console.error("Failed to persist preferred microphone", error); - } - }; - - const handleRememberDevicesChange = (next: boolean) => { - setRememberDevices(next); - - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem( - REMEMBER_DEVICES_KEY, - next ? "true" : "false" - ); - - if (next) { - if (selectedCameraId) { - window.localStorage.setItem(PREFERRED_CAMERA_KEY, selectedCameraId); - } else { - window.localStorage.removeItem(PREFERRED_CAMERA_KEY); - } - - if (selectedMicId) { - window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, selectedMicId); - } else { - window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); - } - } else { - window.localStorage.removeItem(PREFERRED_CAMERA_KEY); - window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); - } - } catch (error) { - console.error("Failed to update recorder preferences", error); - } - }; - - return ( - <> - - - - - { - const originalEvent = event.detail.originalEvent as - | PointerEvent - | undefined; - const target = originalEvent?.target as Element | undefined; - - if (!target) return; - - if (isRecording || isBusy) { - event.preventDefault(); - return; - } - - const path = originalEvent?.composedPath() || []; - const dialogContent = dialogContentRef.current; - - const isInsideDialog = (el: Element) => { - if (!dialogContent) return false; - return dialogContent.contains(el); - }; - - const isWhitelisted = (el: Element) => { - if (isInsideDialog(el)) return true; - if (el.closest('[data-slot="select-content"]')) return true; - if (el.closest("[data-radix-select-content]")) return true; - if (el.closest("[data-radix-select-viewport]")) return true; - if (el.closest("[data-radix-select-item]")) return true; - if (el.closest("[data-camera-preview]")) return true; - return false; - }; - - if ( - (target && isWhitelisted(target)) || - path.some( - (t) => t instanceof Element && isWhitelisted(t as Element) - ) - ) { - event.preventDefault(); - } - }} - onFocusOutside={(event) => { - const target = event.target as Element | undefined; - - if (!target) return; - - if (isRecording || isBusy) { - event.preventDefault(); - return; - } - - const path = - (event.detail?.originalEvent as FocusEvent)?.composedPath?.() || - []; - const dialogContent = dialogContentRef.current; - - const isInsideDialog = (el: Element) => { - if (!dialogContent) return false; - return dialogContent.contains(el); - }; - - const isWhitelisted = (el: Element) => { - if (isInsideDialog(el)) return true; - if (el.closest('[data-slot="select-content"]')) return true; - if (el.closest("[data-radix-select-content]")) return true; - if (el.closest("[data-radix-select-viewport]")) return true; - if (el.closest("[data-radix-select-item]")) return true; - if (el.closest("[data-camera-preview]")) return true; - return false; - }; - - if ( - (target && isWhitelisted(target)) || - path.some( - (t) => t instanceof Element && isWhitelisted(t as Element) - ) - ) { - event.preventDefault(); - } - }} - onInteractOutside={(event) => { - const originalEvent = event.detail.originalEvent as - | Event - | undefined; - const target = originalEvent?.target as Element | undefined; - - if (!target) return; - - if (isRecording || isBusy) { - event.preventDefault(); - return; - } - - const path = originalEvent?.composedPath?.() || []; - const dialogContent = dialogContentRef.current; - - const isInsideDialog = (el: Element) => { - if (!dialogContent) return false; - return dialogContent.contains(el); - }; - - const isWhitelisted = (el: Element) => { - if (isInsideDialog(el)) return true; - if (el.closest('[data-slot="select-content"]')) return true; - if (el.closest("[data-radix-select-content]")) return true; - if (el.closest("[data-radix-select-viewport]")) return true; - if (el.closest("[data-radix-select-item]")) return true; - if (el.closest("[data-camera-preview]")) return true; - return false; - }; - - if ( - (target && isWhitelisted(target)) || - path.some( - (t) => t instanceof Element && isWhitelisted(t as Element) - ) - ) { - event.preventDefault(); - } - }} - > - Instant Mode Recorder - - {open && ( - - {!settingsOpen && ( - - )} - - {settingsOpen && ( - -
- -

- Recorder settings -

- -
-
-
-
-

- Automatically select your last webcam/microphone -

-

- If available, the last used camera and mic will be - automatically selected. -

-
- -
-
-
- )} -
- - {howItWorksOpen && ( - -
- -

- How it works -

- -
-
-
- {HOW_IT_WORKS_ITEMS.map( - ({ title, description, Icon, accent }) => ( -
-
-
- -
-
-

- {title} -

-

- {description} -

-
-
-
- ) - )} -
-
-
- )} -
- - - { - setCameraSelectOpen(isOpen); - if (isOpen) { - setMicSelectOpen(false); - } - }} - onCameraChange={handleCameraChange} - onRefreshDevices={refreshCameras} - /> - { - setMicSelectOpen(isOpen); - if (isOpen) { - setCameraSelectOpen(false); - } - }} - onMicChange={handleMicChange} - onRefreshDevices={refreshMics} - /> - - -
- )} -
-
-
- {selectedCameraId && ( - handleCameraChange(null)} - /> - )} - - ); + const [open, setOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [howItWorksOpen, setHowItWorksOpen] = useState(false); + const [selectedMicId, setSelectedMicId] = useState(null); + const [recordingMode, setRecordingMode] = + useState("fullscreen"); + const [selectedCameraId, setSelectedCameraId] = useState(null); + const [cameraSelectOpen, setCameraSelectOpen] = useState(false); + const [micSelectOpen, setMicSelectOpen] = useState(false); + const [rememberDevices, setRememberDevices] = useState(false); + const dialogContentRef = useRef(null); + + const { activeOrganization } = useDashboardContext(); + const organisationId = activeOrganization?.organization.id; + const { devices: availableMics, refresh: refreshMics } = + useMicrophoneDevices(open); + const { devices: availableCameras, refresh: refreshCameras } = + useCameraDevices(open); + + const micEnabled = selectedMicId !== null; + + useEffect(() => { + if ( + recordingMode === "camera" && + !selectedCameraId && + availableCameras.length > 0 + ) { + setSelectedCameraId(availableCameras[0]?.deviceId ?? null); + } + }, [recordingMode, selectedCameraId, availableCameras]); + + useEffect(() => { + if (typeof window === "undefined") return; + + try { + const storedRemember = window.localStorage.getItem(REMEMBER_DEVICES_KEY); + if (storedRemember === "true") { + setRememberDevices(true); + } + } catch (error) { + console.error("Failed to load recorder preferences", error); + } + }, []); + + useEffect(() => { + if (!open || !rememberDevices) return; + if (typeof window === "undefined") return; + + try { + const storedCameraId = window.localStorage.getItem(PREFERRED_CAMERA_KEY); + if (storedCameraId) { + const hasCamera = availableCameras.some( + (camera) => camera.deviceId === storedCameraId, + ); + if (hasCamera && storedCameraId !== selectedCameraId) { + setSelectedCameraId(storedCameraId); + } + } + + const storedMicId = window.localStorage.getItem(PREFERRED_MICROPHONE_KEY); + if (storedMicId) { + const hasMic = availableMics.some( + (microphone) => microphone.deviceId === storedMicId, + ); + if (hasMic && storedMicId !== selectedMicId) { + setSelectedMicId(storedMicId); + } + } + } catch (error) { + console.error("Failed to restore recorder device selection", error); + } + }, [ + open, + rememberDevices, + availableCameras, + availableMics, + selectedCameraId, + selectedMicId, + ]); + + const { + isRecording, + isBusy, + canStartRecording, + startRecording, + stopRecording, + resetState, + } = useWebRecorder({ + organisationId, + selectedMicId, + micEnabled, + recordingMode, + selectedCameraId, + onRecordingSurfaceDetected: (mode) => { + setRecordingMode(mode); + }, + }); + + const handleOpenChange = (next: boolean) => { + if (!next && isBusy) { + toast.info("Keep this dialog open while your upload finishes."); + return; + } + + if (!next) { + resetState(); + setSelectedCameraId(null); + setRecordingMode("fullscreen"); + setSettingsOpen(false); + setHowItWorksOpen(false); + } + setOpen(next); + }; + + const handleStopClick = () => { + stopRecording().catch((err: unknown) => { + console.error("Stop recording error", err); + }); + }; + + const handleClose = () => { + if (!isBusy) { + handleOpenChange(false); + } + }; + + const handleCameraChange = (cameraId: string | null) => { + setSelectedCameraId(cameraId); + + if (!rememberDevices || typeof window === "undefined") { + return; + } + + try { + if (cameraId) { + window.localStorage.setItem(PREFERRED_CAMERA_KEY, cameraId); + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + } + } catch (error) { + console.error("Failed to persist preferred camera", error); + } + }; + + const handleMicChange = (micId: string | null) => { + setSelectedMicId(micId); + + if (!rememberDevices || typeof window === "undefined") { + return; + } + + try { + if (micId) { + window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, micId); + } else { + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } catch (error) { + console.error("Failed to persist preferred microphone", error); + } + }; + + const handleRememberDevicesChange = (next: boolean) => { + setRememberDevices(next); + + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + REMEMBER_DEVICES_KEY, + next ? "true" : "false", + ); + + if (next) { + if (selectedCameraId) { + window.localStorage.setItem(PREFERRED_CAMERA_KEY, selectedCameraId); + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + } + + if (selectedMicId) { + window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, selectedMicId); + } else { + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } catch (error) { + console.error("Failed to update recorder preferences", error); + } + }; + + return ( + <> + + + + + { + const originalEvent = event.detail.originalEvent as + | PointerEvent + | undefined; + const target = originalEvent?.target as Element | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = originalEvent?.composedPath() || []; + const dialogContent = dialogContentRef.current; + + const isInsideDialog = (el: Element) => { + if (!dialogContent) return false; + return dialogContent.contains(el); + }; + + const isWhitelisted = (el: Element) => { + if (isInsideDialog(el)) return true; + if (el.closest('[data-slot="select-content"]')) return true; + if (el.closest("[data-radix-select-content]")) return true; + if (el.closest("[data-radix-select-viewport]")) return true; + if (el.closest("[data-radix-select-item]")) return true; + if (el.closest("[data-camera-preview]")) return true; + return false; + }; + + if ( + (target && isWhitelisted(target)) || + path.some( + (t) => t instanceof Element && isWhitelisted(t as Element), + ) + ) { + event.preventDefault(); + } + }} + onFocusOutside={(event) => { + const target = event.target as Element | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = + (event.detail?.originalEvent as FocusEvent)?.composedPath?.() || + []; + const dialogContent = dialogContentRef.current; + + const isInsideDialog = (el: Element) => { + if (!dialogContent) return false; + return dialogContent.contains(el); + }; + + const isWhitelisted = (el: Element) => { + if (isInsideDialog(el)) return true; + if (el.closest('[data-slot="select-content"]')) return true; + if (el.closest("[data-radix-select-content]")) return true; + if (el.closest("[data-radix-select-viewport]")) return true; + if (el.closest("[data-radix-select-item]")) return true; + if (el.closest("[data-camera-preview]")) return true; + return false; + }; + + if ( + (target && isWhitelisted(target)) || + path.some( + (t) => t instanceof Element && isWhitelisted(t as Element), + ) + ) { + event.preventDefault(); + } + }} + onInteractOutside={(event) => { + const originalEvent = event.detail.originalEvent as + | Event + | undefined; + const target = originalEvent?.target as Element | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = originalEvent?.composedPath?.() || []; + const dialogContent = dialogContentRef.current; + + const isInsideDialog = (el: Element) => { + if (!dialogContent) return false; + return dialogContent.contains(el); + }; + + const isWhitelisted = (el: Element) => { + if (isInsideDialog(el)) return true; + if (el.closest('[data-slot="select-content"]')) return true; + if (el.closest("[data-radix-select-content]")) return true; + if (el.closest("[data-radix-select-viewport]")) return true; + if (el.closest("[data-radix-select-item]")) return true; + if (el.closest("[data-camera-preview]")) return true; + return false; + }; + + if ( + (target && isWhitelisted(target)) || + path.some( + (t) => t instanceof Element && isWhitelisted(t as Element), + ) + ) { + event.preventDefault(); + } + }} + > + Instant Mode Recorder + + {open && ( + + {!settingsOpen && ( + + )} + + {settingsOpen && ( + +
+ +

+ Recorder settings +

+ +
+
+
+
+

+ Automatically select your last webcam/microphone +

+

+ If available, the last used camera and mic will be + automatically selected. +

+
+ +
+
+
+ )} +
+ + {howItWorksOpen && ( + +
+ +

+ How it works +

+ +
+
+
+ {HOW_IT_WORKS_ITEMS.map( + ({ title, description, Icon, accent }) => ( +
+
+
+ +
+
+

+ {title} +

+

+ {description} +

+
+
+
+ ), + )} +
+
+
+ )} +
+ + + { + setCameraSelectOpen(isOpen); + if (isOpen) { + setMicSelectOpen(false); + } + }} + onCameraChange={handleCameraChange} + onRefreshDevices={refreshCameras} + /> + { + setMicSelectOpen(isOpen); + if (isOpen) { + setCameraSelectOpen(false); + } + }} + onMicChange={handleMicChange} + onRefreshDevices={refreshMics} + /> + + +
+ )} +
+
+
+ {selectedCameraId && ( + handleCameraChange(null)} + /> + )} + + ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx index 75024d22e5..984c626d6c 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialogHeader.tsx @@ -4,95 +4,95 @@ import clsx from "clsx"; import { useDashboardContext } from "../../../Contexts"; interface WebRecorderDialogHeaderProps { - isBusy: boolean; - onClose: () => void; + isBusy: boolean; + onClose: () => void; } export const WebRecorderDialogHeader = ({ - isBusy, - onClose, + isBusy, + onClose, }: WebRecorderDialogHeaderProps) => { - const { user, setUpgradeModalOpen } = useDashboardContext(); - const planLabel = user.isPro ? "Pro" : "Free"; - const planClassName = clsx( - "ml-2 inline-flex items-center rounded-full px-2 text-[0.7rem] font-medium transition-colors", - user.isPro - ? "bg-blue-9 text-gray-1" - : "cursor-pointer bg-gray-3 text-gray-12 hover:bg-gray-4" - ); + const { user, setUpgradeModalOpen } = useDashboardContext(); + const planLabel = user.isPro ? "Pro" : "Free"; + const planClassName = clsx( + "ml-2 inline-flex items-center rounded-full px-2 text-[0.7rem] font-medium transition-colors", + user.isPro + ? "bg-blue-9 text-gray-1" + : "cursor-pointer bg-gray-3 text-gray-12 hover:bg-gray-4", + ); - return ( - <> -
-
-
-
- - Cap Logo - - - - - - - - { - if (!user.isPro) setUpgradeModalOpen(true); - }} - className={planClassName} - > - {planLabel} - -
-
- - ); + return ( + <> +
+
+
+
+ + Cap Logo + + + + + + + + { + if (!user.isPro) setUpgradeModalOpen(true); + }} + className={planClassName} + > + {planLabel} + +
+
+ + ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useCameraDevices.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useCameraDevices.ts index 5e97819e20..3137cce55b 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useCameraDevices.ts +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useCameraDevices.ts @@ -3,54 +3,58 @@ import { useCallback, useEffect, useRef, useState } from "react"; export const useCameraDevices = (open: boolean) => { - const [availableCameras, setAvailableCameras] = useState([]); - const isMountedRef = useRef(false); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - const enumerateDevices = useCallback(async () => { - if (typeof navigator === "undefined" || !navigator.mediaDevices) return; - - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - const videoInputs = devices.filter( - (device) => device.kind === "videoinput" - ); - if (isMountedRef.current) { - setAvailableCameras(videoInputs); - } - } catch (err) { - console.error("Failed to enumerate devices", err); - } - }, []); - - useEffect(() => { - if (!open) return; - - enumerateDevices(); - - const handleDeviceChange = () => { - enumerateDevices(); - }; - - navigator.mediaDevices?.addEventListener("devicechange", handleDeviceChange); - - return () => { - navigator.mediaDevices?.removeEventListener( - "devicechange", - handleDeviceChange - ); - }; - }, [open, enumerateDevices]); - - return { - devices: availableCameras, - refresh: enumerateDevices, - }; + const [availableCameras, setAvailableCameras] = useState( + [], + ); + const isMountedRef = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const enumerateDevices = useCallback(async () => { + if (typeof navigator === "undefined" || !navigator.mediaDevices) return; + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoInputs = devices.filter( + (device) => device.kind === "videoinput", + ); + if (isMountedRef.current) { + setAvailableCameras(videoInputs); + } + } catch (err) { + console.error("Failed to enumerate devices", err); + } + }, []); + + useEffect(() => { + if (!open) return; + + enumerateDevices(); + + const handleDeviceChange = () => { + enumerateDevices(); + }; + + navigator.mediaDevices?.addEventListener( + "devicechange", + handleDeviceChange, + ); + + return () => { + navigator.mediaDevices?.removeEventListener( + "devicechange", + handleDeviceChange, + ); + }; + }, [open, enumerateDevices]); + + return { + devices: availableCameras, + refresh: enumerateDevices, + }; }; - diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMediaPermission.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMediaPermission.ts index c7755fbf70..bdb8c006fa 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMediaPermission.ts +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMediaPermission.ts @@ -4,103 +4,108 @@ import { useCallback, useEffect, useRef, useState } from "react"; type MediaPermissionKind = "camera" | "microphone"; -type MediaPermissionState = - | PermissionState - | "unsupported" - | "unknown"; +type MediaPermissionState = PermissionState | "unsupported" | "unknown"; const permissionNameMap: Record = { - camera: "camera", - microphone: "microphone", + camera: "camera", + microphone: "microphone", }; -const mediaConstraintsMap: Record = { - camera: { video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }, - microphone: { audio: true, video: false }, -}; +const mediaConstraintsMap: Record = + { + camera: { + video: { width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }, + microphone: { audio: true, video: false }, + }; export const useMediaPermission = ( - kind: MediaPermissionKind, - enabled: boolean + kind: MediaPermissionKind, + enabled: boolean, ) => { - const [state, setState] = useState("unknown"); - const permissionStatusRef = useRef(null); - - const updateState = useCallback((next: MediaPermissionState) => { - setState((prev) => { - if (prev === next) return prev; - return next; - }); - }, []); - - const refreshPermission = useCallback(async () => { - if (!enabled) return; - if (typeof navigator === "undefined" || !navigator.permissions?.query) { - updateState("unsupported"); - return; - } - - try { - const descriptor = { - name: permissionNameMap[kind], - } as PermissionDescriptor; - - const permissionStatus = await navigator.permissions.query(descriptor); - if (permissionStatusRef.current) { - permissionStatusRef.current.onchange = null; - } - permissionStatusRef.current = permissionStatus; - - updateState(permissionStatus.state); - - permissionStatus.onchange = () => { - updateState(permissionStatus.state); - }; - } catch (error) { - updateState("unsupported"); - } - }, [enabled, kind, updateState]); - - useEffect(() => { - if (!enabled) return; - refreshPermission(); - - return () => { - if (permissionStatusRef.current) { - permissionStatusRef.current.onchange = null; - } - permissionStatusRef.current = null; - }; - }, [enabled, refreshPermission]); - - const requestPermission = useCallback(async () => { - if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) { - updateState("unsupported"); - return false; - } - - try { - const stream = await navigator.mediaDevices.getUserMedia( - mediaConstraintsMap[kind] - ); - stream.getTracks().forEach((track) => track.stop()); - updateState("granted"); - await refreshPermission(); - return true; - } catch (error) { - if (error instanceof DOMException) { - if (error.name === "NotAllowedError" || error.name === "SecurityError") { - updateState("denied"); - } - } - throw error; - } - }, [kind, refreshPermission, updateState]); - - return { - state, - requestPermission, - }; + const [state, setState] = useState("unknown"); + const permissionStatusRef = useRef(null); + + const updateState = useCallback((next: MediaPermissionState) => { + setState((prev) => { + if (prev === next) return prev; + return next; + }); + }, []); + + const refreshPermission = useCallback(async () => { + if (!enabled) return; + if (typeof navigator === "undefined" || !navigator.permissions?.query) { + updateState("unsupported"); + return; + } + + try { + const descriptor = { + name: permissionNameMap[kind], + } as PermissionDescriptor; + + const permissionStatus = await navigator.permissions.query(descriptor); + if (permissionStatusRef.current) { + permissionStatusRef.current.onchange = null; + } + permissionStatusRef.current = permissionStatus; + + updateState(permissionStatus.state); + + permissionStatus.onchange = () => { + updateState(permissionStatus.state); + }; + } catch (error) { + updateState("unsupported"); + } + }, [enabled, kind, updateState]); + + useEffect(() => { + if (!enabled) return; + refreshPermission(); + + return () => { + if (permissionStatusRef.current) { + permissionStatusRef.current.onchange = null; + } + permissionStatusRef.current = null; + }; + }, [enabled, refreshPermission]); + + const requestPermission = useCallback(async () => { + if ( + typeof navigator === "undefined" || + !navigator.mediaDevices?.getUserMedia + ) { + updateState("unsupported"); + return false; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia( + mediaConstraintsMap[kind], + ); + stream.getTracks().forEach((track) => track.stop()); + updateState("granted"); + await refreshPermission(); + return true; + } catch (error) { + if (error instanceof DOMException) { + if ( + error.name === "NotAllowedError" || + error.name === "SecurityError" + ) { + updateState("denied"); + } + } + throw error; + } + }, [kind, refreshPermission, updateState]); + + return { + state, + requestPermission, + }; }; - - diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMicrophoneDevices.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMicrophoneDevices.ts index e4d996bb36..11c1d3e191 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMicrophoneDevices.ts +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useMicrophoneDevices.ts @@ -3,54 +3,56 @@ import { useCallback, useEffect, useRef, useState } from "react"; export const useMicrophoneDevices = (open: boolean) => { - const [availableMics, setAvailableMics] = useState([]); - const isMountedRef = useRef(false); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - const enumerateDevices = useCallback(async () => { - if (typeof navigator === "undefined" || !navigator.mediaDevices) return; - - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - const audioInputs = devices.filter( - (device) => device.kind === "audioinput" - ); - if (isMountedRef.current) { - setAvailableMics(audioInputs); - } - } catch (err) { - console.error("Failed to enumerate devices", err); - } - }, []); - - useEffect(() => { - if (!open) return; - - enumerateDevices(); - - const handleDeviceChange = () => { - enumerateDevices(); - }; - - navigator.mediaDevices?.addEventListener("devicechange", handleDeviceChange); - - return () => { - navigator.mediaDevices?.removeEventListener( - "devicechange", - handleDeviceChange - ); - }; - }, [open, enumerateDevices]); - - return { - devices: availableMics, - refresh: enumerateDevices, - }; + const [availableMics, setAvailableMics] = useState([]); + const isMountedRef = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const enumerateDevices = useCallback(async () => { + if (typeof navigator === "undefined" || !navigator.mediaDevices) return; + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices.filter( + (device) => device.kind === "audioinput", + ); + if (isMountedRef.current) { + setAvailableMics(audioInputs); + } + } catch (err) { + console.error("Failed to enumerate devices", err); + } + }, []); + + useEffect(() => { + if (!open) return; + + enumerateDevices(); + + const handleDeviceChange = () => { + enumerateDevices(); + }; + + navigator.mediaDevices?.addEventListener( + "devicechange", + handleDeviceChange, + ); + + return () => { + navigator.mediaDevices?.removeEventListener( + "devicechange", + handleDeviceChange, + ); + }; + }, [open, enumerateDevices]); + + return { + devices: availableMics, + refresh: enumerateDevices, + }; }; - diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts index d1528b4baf..f057b61dfa 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useWebRecorder.ts @@ -1,929 +1,930 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { Organisation } from "@cap/web-domain"; +import { useQueryClient } from "@tanstack/react-query"; import { Option } from "effect"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { useRouter } from "next/navigation"; import { createVideoAndGetUploadUrl } from "@/actions/video/upload"; -import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest"; import { EffectRuntime, useRpcClient } from "@/lib/EffectRuntime"; +import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest"; import { useUploadingContext } from "../../UploadingContext"; import { sendProgressUpdate } from "../sendProgressUpdate"; +import type { RecordingMode } from "./RecordingModeSelector"; import type { - PresignedPost, - RecorderErrorEvent, - RecorderPhase, - VideoId, + PresignedPost, + RecorderErrorEvent, + RecorderPhase, + VideoId, } from "./web-recorder-types"; -import type { RecordingMode } from "./RecordingModeSelector"; interface UseWebRecorderOptions { - organisationId: string | undefined; - selectedMicId: string | null; - micEnabled: boolean; - recordingMode: RecordingMode; - selectedCameraId: string | null; - onPhaseChange?: (phase: RecorderPhase) => void; - onRecordingSurfaceDetected?: (mode: RecordingMode) => void; + organisationId: string | undefined; + selectedMicId: string | null; + micEnabled: boolean; + recordingMode: RecordingMode; + selectedCameraId: string | null; + onPhaseChange?: (phase: RecorderPhase) => void; + onRecordingSurfaceDetected?: (mode: RecordingMode) => void; } const DISPLAY_MEDIA_VIDEO_CONSTRAINTS: MediaTrackConstraints = { - frameRate: { ideal: 30 }, - width: { ideal: 1920 }, - height: { ideal: 1080 }, + frameRate: { ideal: 30 }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, }; type ExtendedDisplayMediaStreamOptions = DisplayMediaStreamOptions & { - monitorTypeSurfaces?: "include" | "exclude"; - surfaceSwitching?: "include" | "exclude"; - selfBrowserSurface?: "include" | "exclude"; - preferCurrentTab?: boolean; + monitorTypeSurfaces?: "include" | "exclude"; + surfaceSwitching?: "include" | "exclude"; + selfBrowserSurface?: "include" | "exclude"; + preferCurrentTab?: boolean; }; const DISPLAY_MODE_PREFERENCES: Record< - Exclude, - Partial + Exclude, + Partial > = { - fullscreen: { - monitorTypeSurfaces: "include", - selfBrowserSurface: "exclude", - surfaceSwitching: "exclude", - preferCurrentTab: false, - }, - window: { - monitorTypeSurfaces: "exclude", - selfBrowserSurface: "exclude", - surfaceSwitching: "exclude", - preferCurrentTab: false, - }, - tab: { - monitorTypeSurfaces: "exclude", - selfBrowserSurface: "include", - surfaceSwitching: "exclude", - preferCurrentTab: true, - }, + fullscreen: { + monitorTypeSurfaces: "include", + selfBrowserSurface: "exclude", + surfaceSwitching: "exclude", + preferCurrentTab: false, + }, + window: { + monitorTypeSurfaces: "exclude", + selfBrowserSurface: "exclude", + surfaceSwitching: "exclude", + preferCurrentTab: false, + }, + tab: { + monitorTypeSurfaces: "exclude", + selfBrowserSurface: "include", + surfaceSwitching: "exclude", + preferCurrentTab: true, + }, }; type DetectedDisplayRecordingMode = Exclude; const DISPLAY_SURFACE_TO_RECORDING_MODE: Record< - string, - DetectedDisplayRecordingMode + string, + DetectedDisplayRecordingMode > = { - monitor: "fullscreen", - screen: "fullscreen", - window: "window", - application: "window", - browser: "tab", - tab: "tab", + monitor: "fullscreen", + screen: "fullscreen", + window: "window", + application: "window", + browser: "tab", + tab: "tab", }; const RECORDING_MODE_TO_DISPLAY_SURFACE: Record< - DetectedDisplayRecordingMode, - DisplaySurfacePreference + DetectedDisplayRecordingMode, + DisplaySurfacePreference > = { - fullscreen: "monitor", - window: "window", - tab: "browser", + fullscreen: "monitor", + window: "window", + tab: "browser", }; type DisplaySurfacePreference = - | "monitor" - | "window" - | "browser" - | "application"; + | "monitor" + | "window" + | "browser" + | "application"; const detectRecordingModeFromTrack = ( - track: MediaStreamTrack | null, - settings?: MediaTrackSettings + track: MediaStreamTrack | null, + settings?: MediaTrackSettings, ): DetectedDisplayRecordingMode | null => { - if (!track) return null; - - const trackSettings = settings ?? track.getSettings(); - const maybeDisplaySurface = ( - trackSettings as Partial<{ displaySurface?: unknown }> - ).displaySurface; - const rawSurface = - typeof maybeDisplaySurface === "string" ? maybeDisplaySurface : ""; - const normalizedSurface = rawSurface.toLowerCase(); - - if (normalizedSurface) { - const mapped = DISPLAY_SURFACE_TO_RECORDING_MODE[normalizedSurface]; - if (mapped) { - return mapped; - } - } - - const label = track.label?.toLowerCase() ?? ""; - - if ( - label.includes("screen") || - label.includes("display") || - label.includes("monitor") - ) { - return "fullscreen"; - } - - if (label.includes("window") || label.includes("application")) { - return "window"; - } - - if (label.includes("tab") || label.includes("browser")) { - return "tab"; - } - - return null; + if (!track) return null; + + const trackSettings = settings ?? track.getSettings(); + const maybeDisplaySurface = ( + trackSettings as Partial<{ displaySurface?: unknown }> + ).displaySurface; + const rawSurface = + typeof maybeDisplaySurface === "string" ? maybeDisplaySurface : ""; + const normalizedSurface = rawSurface.toLowerCase(); + + if (normalizedSurface) { + const mapped = DISPLAY_SURFACE_TO_RECORDING_MODE[normalizedSurface]; + if (mapped) { + return mapped; + } + } + + const label = track.label?.toLowerCase() ?? ""; + + if ( + label.includes("screen") || + label.includes("display") || + label.includes("monitor") + ) { + return "fullscreen"; + } + + if (label.includes("window") || label.includes("application")) { + return "window"; + } + + if (label.includes("tab") || label.includes("browser")) { + return "tab"; + } + + return null; }; const detectionRetryDelays = [120, 450, 1000]; const shouldRetryDisplayMediaWithoutPreferences = (error: unknown) => { - if (error instanceof DOMException) { - return ( - error.name === "OverconstrainedError" || - error.name === "NotSupportedError" - ); - } - - return error instanceof TypeError; + if (error instanceof DOMException) { + return ( + error.name === "OverconstrainedError" || + error.name === "NotSupportedError" + ); + } + + return error instanceof TypeError; }; export const useWebRecorder = ({ - organisationId, - selectedMicId, - micEnabled, - recordingMode, - selectedCameraId, - onPhaseChange, - onRecordingSurfaceDetected, + organisationId, + selectedMicId, + micEnabled, + recordingMode, + selectedCameraId, + onPhaseChange, + onRecordingSurfaceDetected, }: UseWebRecorderOptions) => { - const [phase, setPhase] = useState("idle"); - const [durationMs, setDurationMs] = useState(0); - const [videoId, setVideoId] = useState(null); - const [hasAudioTrack, setHasAudioTrack] = useState(false); - const [isSettingUp, setIsSettingUp] = useState(false); - - const mediaRecorderRef = useRef(null); - const recordedChunksRef = useRef([]); - const displayStreamRef = useRef(null); - const cameraStreamRef = useRef(null); - const micStreamRef = useRef(null); - const mixedStreamRef = useRef(null); - const videoRef = useRef(null); - const timerRef = useRef(null); - const startTimeRef = useRef(null); - const dimensionsRef = useRef<{ width?: number; height?: number }>({}); - const stopPromiseResolverRef = useRef<((blob: Blob) => void) | null>(null); - const stopPromiseRejectRef = useRef<((reason?: unknown) => void) | null>( - null - ); - const stopRecordingRef = useRef<(() => Promise) | null>(null); - const recordingModeRef = useRef(recordingMode); - const detectionTimeoutsRef = useRef([]); - const detectionCleanupRef = useRef void>>([]); - - const rpc = useRpcClient(); - const router = useRouter(); - const { setUploadStatus } = useUploadingContext(); - const queryClient = useQueryClient(); - - const updatePhase = useCallback( - (newPhase: RecorderPhase) => { - setPhase(newPhase); - onPhaseChange?.(newPhase); - }, - [onPhaseChange] - ); - - const clearDetectionTracking = useCallback(() => { - detectionTimeoutsRef.current.forEach((timeoutId) => { - window.clearTimeout(timeoutId); - }); - detectionTimeoutsRef.current = []; - detectionCleanupRef.current.forEach((cleanup) => { - try { - cleanup(); - } catch { - /* ignore */ - } - }); - detectionCleanupRef.current = []; - }, []); - - const cleanupStreams = useCallback(() => { - clearDetectionTracking(); - const stopTracks = (stream: MediaStream | null) => { - stream?.getTracks().forEach((track) => { - track.stop(); - }); - }; - stopTracks(displayStreamRef.current); - stopTracks(cameraStreamRef.current); - stopTracks(micStreamRef.current); - stopTracks(mixedStreamRef.current); - displayStreamRef.current = null; - cameraStreamRef.current = null; - micStreamRef.current = null; - mixedStreamRef.current = null; - - if (videoRef.current) { - videoRef.current.srcObject = null; - } - }, [clearDetectionTracking]); - - const clearTimer = useCallback(() => { - if (timerRef.current !== null) { - window.clearInterval(timerRef.current); - timerRef.current = null; - } - }, []); - - const notifyDetectedMode = useCallback( - (detected: DetectedDisplayRecordingMode | null) => { - if (!detected) return; - if (detected === recordingModeRef.current) return; - recordingModeRef.current = detected; - onRecordingSurfaceDetected?.(detected); - }, - [onRecordingSurfaceDetected] - ); - - const scheduleSurfaceDetection = useCallback( - (track: MediaStreamTrack | null, initialSettings?: MediaTrackSettings) => { - if (!track || !onRecordingSurfaceDetected) { - return; - } - - clearDetectionTracking(); - - const attemptDetection = (settingsOverride?: MediaTrackSettings) => { - notifyDetectedMode( - detectRecordingModeFromTrack(track, settingsOverride) - ); - }; - - attemptDetection(initialSettings); - - detectionRetryDelays.forEach((delay) => { - const timeoutId = window.setTimeout(() => { - attemptDetection(); - }, delay); - detectionTimeoutsRef.current.push(timeoutId); - }); - - const handleTrackReady = () => { - attemptDetection(); - }; - - track.addEventListener("unmute", handleTrackReady, { once: true }); - track.addEventListener("mute", handleTrackReady, { once: true }); - detectionCleanupRef.current.push(() => { - track.removeEventListener("unmute", handleTrackReady); - track.removeEventListener("mute", handleTrackReady); - }); - }, - [ - clearDetectionTracking, - notifyDetectedMode, - onRecordingSurfaceDetected, - ] - ); - - const resetState = useCallback(() => { - cleanupStreams(); - clearTimer(); - mediaRecorderRef.current = null; - recordedChunksRef.current = []; - setDurationMs(0); - updatePhase("idle"); - setVideoId(null); - setHasAudioTrack(false); - setUploadStatus(undefined); - }, [cleanupStreams, clearTimer, setUploadStatus, updatePhase]); - - useEffect(() => { - recordingModeRef.current = recordingMode; - }, [recordingMode]); - - useEffect(() => { - return () => { - resetState(); - }; - }, [resetState]); - - const stopRecordingInternal = useCallback(async () => { - const recorder = mediaRecorderRef.current; - if (!recorder || recorder.state === "inactive") return null; - - const stopPromise = new Promise((resolve, reject) => { - stopPromiseResolverRef.current = resolve; - stopPromiseRejectRef.current = reject; - }); - - recorder.stop(); - cleanupStreams(); - clearTimer(); - - return stopPromise; - }, [cleanupStreams, clearTimer]); - - const onRecorderDataAvailable = useCallback((event: BlobEvent) => { - if (event.data && event.data.size > 0) { - recordedChunksRef.current.push(event.data); - } - }, []); - - const onRecorderStop = useCallback(() => { - if (recordedChunksRef.current.length === 0) { - stopPromiseRejectRef.current?.(new Error("No recorded data")); - stopPromiseResolverRef.current = null; - stopPromiseRejectRef.current = null; - return; - } - - const blob = new Blob(recordedChunksRef.current, { - type: recordedChunksRef.current[0]?.type ?? "video/webm;codecs=vp8,opus", - }); - recordedChunksRef.current = []; - stopPromiseResolverRef.current?.(blob); - stopPromiseResolverRef.current = null; - stopPromiseRejectRef.current = null; - }, []); - - const onRecorderError = useCallback((event: RecorderErrorEvent) => { - const error = event.error ?? new DOMException("Recording error"); - stopPromiseRejectRef.current?.(error); - stopPromiseResolverRef.current = null; - stopPromiseRejectRef.current = null; - }, []); - - const captureThumbnail = useCallback( - (source: Blob) => - new Promise((resolve) => { - const video = document.createElement("video"); - const objectUrl = URL.createObjectURL(source); - video.src = objectUrl; - video.muted = true; - video.playsInline = true; - - let timeoutId: number; - - const cleanup = () => { - video.pause(); - video.removeAttribute("src"); - video.load(); - URL.revokeObjectURL(objectUrl); - }; - - const finalize = (result: Blob | null) => { - window.clearTimeout(timeoutId); - cleanup(); - resolve(result); - }; - - timeoutId = window.setTimeout(() => finalize(null), 10000); - - video.addEventListener( - "error", - () => { - finalize(null); - }, - { once: true } - ); - - video.addEventListener( - "loadedmetadata", - () => { - try { - const duration = Number.isFinite(video.duration) - ? video.duration - : 0; - const targetTime = duration > 0 ? Math.min(1, duration / 4) : 0; - video.currentTime = targetTime; - } catch { - finalize(null); - } - }, - { once: true } - ); - - video.addEventListener( - "seeked", - () => { - try { - const canvas = document.createElement("canvas"); - const width = - video.videoWidth || - dimensionsRef.current.width || - 640; - const height = - video.videoHeight || - dimensionsRef.current.height || - 360; - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - if (!ctx) { - finalize(null); - return; - } - ctx.drawImage(video, 0, 0, width, height); - canvas.toBlob( - (blob) => { - finalize(blob ?? null); - }, - "image/jpeg", - 0.8 - ); - } catch { - finalize(null); - } - }, - { once: true } - ); - }), - [] - ); - - const convertToMp4 = useCallback( - async (blob: Blob, hasAudio: boolean, currentVideoId: string) => { - updatePhase("converting"); - setUploadStatus({ - status: "converting", - capId: currentVideoId, - progress: 0, - }); - - const file = new File([blob], "recording.webm", { type: blob.type }); - const { convertMedia } = await import("@remotion/webcodecs"); - - const result = await convertMedia({ - src: file, - container: "mp4", - videoCodec: "h264", - ...(hasAudio ? { audioCodec: "aac" as const } : {}), - onProgress: ({ overallProgress }) => { - if (overallProgress !== null) { - const percent = Math.min(100, Math.max(0, overallProgress * 100)); - setUploadStatus({ - status: "converting", - capId: currentVideoId, - progress: percent, - }); - } - }, - }); - - const savedFile = await result.save(); - if (savedFile.size === 0) { - throw new Error("Conversion produced empty file"); - } - if (savedFile.type !== "video/mp4") { - return new File([savedFile], "result.mp4", { type: "video/mp4" }); - } - return savedFile; - }, - [updatePhase, setUploadStatus] - ); - - const uploadRecording = useCallback( - async ( - blob: Blob, - upload: PresignedPost, - currentVideoId: VideoId, - thumbnailPreviewUrl: string | undefined - ) => - new Promise((resolve, reject) => { - if (blob.size === 0) { - reject(new Error("Cannot upload empty file")); - return; - } - - const fileBlob = - blob instanceof File && blob.type === "video/mp4" - ? blob - : new File([blob], "result.mp4", { type: "video/mp4" }); - - console.log("Uploading file:", { - size: fileBlob.size, - type: fileBlob.type, - name: fileBlob.name, - uploadUrl: upload.url, - uploadFields: upload.fields, - }); - - const formData = new FormData(); - Object.entries(upload.fields).forEach(([key, value]) => { - formData.append(key, value); - }); - formData.append("file", fileBlob, "result.mp4"); - - const xhr = new XMLHttpRequest(); - xhr.open("POST", upload.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - setUploadStatus({ - status: "uploadingVideo", - capId: currentVideoId, - progress: percent, - thumbnailUrl: thumbnailPreviewUrl, - }); - sendProgressUpdate(currentVideoId, event.loaded, event.total); - } - }; - - xhr.onload = async () => { - if (xhr.status >= 200 && xhr.status < 300) { - await sendProgressUpdate(currentVideoId, blob.size, blob.size); - resolve(); - } else { - const errorText = xhr.responseText || xhr.statusText || "Unknown error"; - console.error("Upload failed:", { - status: xhr.status, - statusText: xhr.statusText, - responseText: errorText, - }); - reject( - new Error( - `Upload failed with status ${xhr.status}: ${errorText}` - ) - ); - } - }; - - xhr.onerror = () => { - reject(new Error("Upload failed due to network error")); - }; - - xhr.send(formData); - }), - [setUploadStatus] - ); - - const startRecording = async () => { - if (!organisationId) { - toast.error("Select an organization before recording."); - return; - } - - if (recordingMode === "camera" && !selectedCameraId) { - toast.error("Select a camera before recording."); - return; - } - - setIsSettingUp(true); - - try { - let videoStream: MediaStream | null = null; - let firstTrack: MediaStreamTrack | null = null; - - if (recordingMode === "camera") { - if (!selectedCameraId) { - throw new Error("Camera ID is required for camera-only mode"); - } - videoStream = await navigator.mediaDevices.getUserMedia({ - video: { - deviceId: { exact: selectedCameraId }, - frameRate: { ideal: 30 }, - width: { ideal: 1920 }, - height: { ideal: 1080 }, - }, - }); - cameraStreamRef.current = videoStream; - firstTrack = videoStream.getVideoTracks()[0] ?? null; - } else { - const desiredSurface = - RECORDING_MODE_TO_DISPLAY_SURFACE[recordingMode as DetectedDisplayRecordingMode]; - const videoConstraints: MediaTrackConstraints & { - displaySurface?: DisplaySurfacePreference; - } = { ...DISPLAY_MEDIA_VIDEO_CONSTRAINTS, displaySurface: desiredSurface }; - - const baseDisplayRequest: ExtendedDisplayMediaStreamOptions = { - video: videoConstraints, - audio: false, - preferCurrentTab: recordingMode === "tab", - }; - - const preferredOptions = DISPLAY_MODE_PREFERENCES[recordingMode]; - - if (preferredOptions) { - const preferredDisplayRequest: DisplayMediaStreamOptions = { - ...baseDisplayRequest, - ...preferredOptions, - video: videoConstraints, - }; - - try { - videoStream = await navigator.mediaDevices.getDisplayMedia( - preferredDisplayRequest - ); - } catch (displayError) { - if (shouldRetryDisplayMediaWithoutPreferences(displayError)) { - console.warn( - "Display media preferences not supported, retrying without them", - displayError - ); - videoStream = await navigator.mediaDevices.getDisplayMedia( - baseDisplayRequest - ); - } else { - throw displayError; - } - } - } - - if (!videoStream) { - videoStream = await navigator.mediaDevices.getDisplayMedia( - baseDisplayRequest - ); - } - displayStreamRef.current = videoStream; - firstTrack = videoStream.getVideoTracks()[0] ?? null; - } - - const settings = firstTrack?.getSettings(); - - if (recordingMode !== "camera") { - scheduleSurfaceDetection(firstTrack, settings); - } - - dimensionsRef.current = { - width: settings?.width || undefined, - height: settings?.height || undefined, - }; - - let micStream: MediaStream | null = null; - if (micEnabled && selectedMicId) { - try { - micStream = await navigator.mediaDevices.getUserMedia({ - audio: { - deviceId: { exact: selectedMicId }, - echoCancellation: true, - autoGainControl: true, - noiseSuppression: true, - }, - }); - } catch (micError) { - console.warn("Microphone permission denied", micError); - toast.warning("Microphone unavailable. Recording without audio."); - micStream = null; - } - } - - if (micStream) { - micStreamRef.current = micStream; - } - - const mixedStream = new MediaStream([ - ...videoStream.getVideoTracks(), - ...(micStream ? micStream.getAudioTracks() : []), - ]); - - mixedStreamRef.current = mixedStream; - setHasAudioTrack(mixedStream.getAudioTracks().length > 0); - - recordedChunksRef.current = []; - - const mimeTypeCandidates = [ - "video/webm;codecs=vp9,opus", - "video/webm;codecs=vp8,opus", - "video/webm", - ]; - const mimeType = mimeTypeCandidates.find((candidate) => - MediaRecorder.isTypeSupported(candidate) - ); - - const recorder = new MediaRecorder( - mixedStream, - mimeType ? { mimeType } : undefined - ); - recorder.ondataavailable = onRecorderDataAvailable; - recorder.onstop = onRecorderStop; - recorder.onerror = onRecorderError; - - const handleVideoEnded = () => { - stopRecordingRef.current?.().catch(() => { - /* ignore */ - }); - }; - - firstTrack?.addEventListener("ended", handleVideoEnded, { once: true }); - - mediaRecorderRef.current = recorder; - recorder.start(200); - - startTimeRef.current = performance.now(); - setDurationMs(0); - updatePhase("recording"); - - timerRef.current = window.setInterval(() => { - if (startTimeRef.current !== null) - setDurationMs(performance.now() - startTimeRef.current); - }, 250); - } catch (err) { - console.error("Failed to start recording", err); - toast.error("Could not start recording."); - resetState(); - } finally { - setIsSettingUp(false); - } - }; - - const stopRecording = useCallback(async () => { - if (phase !== "recording") return; - - let createdVideoId: VideoId | null = null; - const orgId = organisationId; - if (!orgId) { - updatePhase("error"); - return; - } - - const brandedOrgId = Organisation.OrganisationId.make(orgId); - - let thumbnailBlob: Blob | null = null; - let thumbnailPreviewUrl: string | undefined; - - try { - updatePhase("creating"); - - const blob = await stopRecordingInternal(); - if (!blob) { - throw new Error("No recording available"); - } - - const durationSeconds = Math.max(1, Math.round(durationMs / 1000)); - const width = dimensionsRef.current.width; - const height = dimensionsRef.current.height; - const resolution = width && height ? `${width}x${height}` : undefined; - - setUploadStatus({ status: "creating" }); - - const result = await EffectRuntime.runPromise( - rpc.VideoInstantCreate({ - orgId: brandedOrgId, - folderId: Option.none(), - resolution, - durationSeconds, - width, - height, - videoCodec: "h264", - audioCodec: hasAudioTrack ? "aac" : undefined, - supportsUploadProgress: true, - }) - ); - - createdVideoId = result.id; - setVideoId(result.id); - - const mp4Blob = await convertToMp4(blob, hasAudioTrack, result.id); - - thumbnailBlob = await captureThumbnail(mp4Blob); - thumbnailPreviewUrl = thumbnailBlob - ? URL.createObjectURL(thumbnailBlob) - : undefined; - - updatePhase("uploading"); - setUploadStatus({ - status: "uploadingVideo", - capId: result.id, - progress: 0, - thumbnailUrl: thumbnailPreviewUrl, - }); - - await uploadRecording(mp4Blob, result.upload, result.id, thumbnailPreviewUrl); - - if (thumbnailBlob) { - try { - const screenshotData = await createVideoAndGetUploadUrl({ - videoId: result.id, - isScreenshot: true, - orgId: brandedOrgId, - }); - - const screenshotFormData = new FormData(); - Object.entries(screenshotData.presignedPostData.fields).forEach( - ([key, value]) => { - screenshotFormData.append(key, value as string); - } - ); - screenshotFormData.append( - "file", - thumbnailBlob, - "screen-capture.jpg" - ); - - setUploadStatus({ - status: "uploadingThumbnail", - capId: result.id, - progress: 90, - }); - - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", screenshotData.presignedPostData.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = 90 + (event.loaded / event.total) * 10; - setUploadStatus({ - status: "uploadingThumbnail", - capId: result.id, - progress: percent, - }); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject( - new Error( - `Screenshot upload failed with status ${xhr.status}` - ) - ); - } - }; - - xhr.onerror = () => { - reject(new Error("Screenshot upload failed")); - }; - - xhr.send(screenshotFormData); - }); - - queryClient.refetchQueries({ - queryKey: ThumbnailRequest.queryKey(result.id), - }); - } catch (thumbnailError) { - console.error("Failed to upload thumbnail", thumbnailError); - toast.warning( - "Recording uploaded, but thumbnail failed to upload." - ); - } - } - - setUploadStatus(undefined); - updatePhase("completed"); - toast.success("Recording uploaded"); - router.refresh(); - } catch (err) { - console.error("Failed to process recording", err); - setUploadStatus(undefined); - updatePhase("error"); - - const idToDelete = createdVideoId ?? videoId; - if (idToDelete) { - EffectRuntime.runPromise(rpc.VideoDelete(idToDelete)).catch(() => { - /* ignore */ - }); - } - } finally { - if (thumbnailPreviewUrl) { - URL.revokeObjectURL(thumbnailPreviewUrl); - } - } - }, [ - phase, - organisationId, - durationMs, - hasAudioTrack, - videoId, - updatePhase, - setUploadStatus, - rpc, - router, - convertToMp4, - uploadRecording, - stopRecordingInternal, - captureThumbnail, - queryClient, - ]); - - useEffect(() => { - stopRecordingRef.current = stopRecording; - }, [stopRecording]); - - return { - phase, - durationMs, - videoId, - hasAudioTrack, - isSettingUp, - isRecording: phase === "recording", - isBusy: - phase === "recording" || - phase === "creating" || - phase === "converting" || - phase === "uploading", - canStartRecording: Boolean(organisationId) && !isSettingUp, - startRecording, - stopRecording, - resetState, - }; + const [phase, setPhase] = useState("idle"); + const [durationMs, setDurationMs] = useState(0); + const [videoId, setVideoId] = useState(null); + const [hasAudioTrack, setHasAudioTrack] = useState(false); + const [isSettingUp, setIsSettingUp] = useState(false); + + const mediaRecorderRef = useRef(null); + const recordedChunksRef = useRef([]); + const displayStreamRef = useRef(null); + const cameraStreamRef = useRef(null); + const micStreamRef = useRef(null); + const mixedStreamRef = useRef(null); + const videoRef = useRef(null); + const timerRef = useRef(null); + const startTimeRef = useRef(null); + const dimensionsRef = useRef<{ width?: number; height?: number }>({}); + const stopPromiseResolverRef = useRef<((blob: Blob) => void) | null>(null); + const stopPromiseRejectRef = useRef<((reason?: unknown) => void) | null>( + null, + ); + const stopRecordingRef = useRef<(() => Promise) | null>(null); + const recordingModeRef = useRef(recordingMode); + const detectionTimeoutsRef = useRef([]); + const detectionCleanupRef = useRef void>>([]); + + const rpc = useRpcClient(); + const router = useRouter(); + const { setUploadStatus } = useUploadingContext(); + const queryClient = useQueryClient(); + + const updatePhase = useCallback( + (newPhase: RecorderPhase) => { + setPhase(newPhase); + onPhaseChange?.(newPhase); + }, + [onPhaseChange], + ); + + const clearDetectionTracking = useCallback(() => { + detectionTimeoutsRef.current.forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + detectionTimeoutsRef.current = []; + detectionCleanupRef.current.forEach((cleanup) => { + try { + cleanup(); + } catch { + /* ignore */ + } + }); + detectionCleanupRef.current = []; + }, []); + + const cleanupStreams = useCallback(() => { + clearDetectionTracking(); + const stopTracks = (stream: MediaStream | null) => { + stream?.getTracks().forEach((track) => { + track.stop(); + }); + }; + stopTracks(displayStreamRef.current); + stopTracks(cameraStreamRef.current); + stopTracks(micStreamRef.current); + stopTracks(mixedStreamRef.current); + displayStreamRef.current = null; + cameraStreamRef.current = null; + micStreamRef.current = null; + mixedStreamRef.current = null; + + if (videoRef.current) { + videoRef.current.srcObject = null; + } + }, [clearDetectionTracking]); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const notifyDetectedMode = useCallback( + (detected: DetectedDisplayRecordingMode | null) => { + if (!detected) return; + if (detected === recordingModeRef.current) return; + recordingModeRef.current = detected; + onRecordingSurfaceDetected?.(detected); + }, + [onRecordingSurfaceDetected], + ); + + const scheduleSurfaceDetection = useCallback( + (track: MediaStreamTrack | null, initialSettings?: MediaTrackSettings) => { + if (!track || !onRecordingSurfaceDetected) { + return; + } + + clearDetectionTracking(); + + const attemptDetection = (settingsOverride?: MediaTrackSettings) => { + notifyDetectedMode( + detectRecordingModeFromTrack(track, settingsOverride), + ); + }; + + attemptDetection(initialSettings); + + detectionRetryDelays.forEach((delay) => { + const timeoutId = window.setTimeout(() => { + attemptDetection(); + }, delay); + detectionTimeoutsRef.current.push(timeoutId); + }); + + const handleTrackReady = () => { + attemptDetection(); + }; + + track.addEventListener("unmute", handleTrackReady, { once: true }); + track.addEventListener("mute", handleTrackReady, { once: true }); + detectionCleanupRef.current.push(() => { + track.removeEventListener("unmute", handleTrackReady); + track.removeEventListener("mute", handleTrackReady); + }); + }, + [clearDetectionTracking, notifyDetectedMode, onRecordingSurfaceDetected], + ); + + const resetState = useCallback(() => { + cleanupStreams(); + clearTimer(); + mediaRecorderRef.current = null; + recordedChunksRef.current = []; + setDurationMs(0); + updatePhase("idle"); + setVideoId(null); + setHasAudioTrack(false); + setUploadStatus(undefined); + }, [cleanupStreams, clearTimer, setUploadStatus, updatePhase]); + + useEffect(() => { + recordingModeRef.current = recordingMode; + }, [recordingMode]); + + useEffect(() => { + return () => { + resetState(); + }; + }, [resetState]); + + const stopRecordingInternal = useCallback(async () => { + const recorder = mediaRecorderRef.current; + if (!recorder || recorder.state === "inactive") return null; + + const stopPromise = new Promise((resolve, reject) => { + stopPromiseResolverRef.current = resolve; + stopPromiseRejectRef.current = reject; + }); + + recorder.stop(); + cleanupStreams(); + clearTimer(); + + return stopPromise; + }, [cleanupStreams, clearTimer]); + + const onRecorderDataAvailable = useCallback((event: BlobEvent) => { + if (event.data && event.data.size > 0) { + recordedChunksRef.current.push(event.data); + } + }, []); + + const onRecorderStop = useCallback(() => { + if (recordedChunksRef.current.length === 0) { + stopPromiseRejectRef.current?.(new Error("No recorded data")); + stopPromiseResolverRef.current = null; + stopPromiseRejectRef.current = null; + return; + } + + const blob = new Blob(recordedChunksRef.current, { + type: recordedChunksRef.current[0]?.type ?? "video/webm;codecs=vp8,opus", + }); + recordedChunksRef.current = []; + stopPromiseResolverRef.current?.(blob); + stopPromiseResolverRef.current = null; + stopPromiseRejectRef.current = null; + }, []); + + const onRecorderError = useCallback((event: RecorderErrorEvent) => { + const error = event.error ?? new DOMException("Recording error"); + stopPromiseRejectRef.current?.(error); + stopPromiseResolverRef.current = null; + stopPromiseRejectRef.current = null; + }, []); + + const captureThumbnail = useCallback( + (source: Blob) => + new Promise((resolve) => { + const video = document.createElement("video"); + const objectUrl = URL.createObjectURL(source); + video.src = objectUrl; + video.muted = true; + video.playsInline = true; + + let timeoutId: number; + + const cleanup = () => { + video.pause(); + video.removeAttribute("src"); + video.load(); + URL.revokeObjectURL(objectUrl); + }; + + const finalize = (result: Blob | null) => { + window.clearTimeout(timeoutId); + cleanup(); + resolve(result); + }; + + timeoutId = window.setTimeout(() => finalize(null), 10000); + + video.addEventListener( + "error", + () => { + finalize(null); + }, + { once: true }, + ); + + video.addEventListener( + "loadedmetadata", + () => { + try { + const duration = Number.isFinite(video.duration) + ? video.duration + : 0; + const targetTime = duration > 0 ? Math.min(1, duration / 4) : 0; + video.currentTime = targetTime; + } catch { + finalize(null); + } + }, + { once: true }, + ); + + video.addEventListener( + "seeked", + () => { + try { + const canvas = document.createElement("canvas"); + const width = + video.videoWidth || dimensionsRef.current.width || 640; + const height = + video.videoHeight || dimensionsRef.current.height || 360; + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + finalize(null); + return; + } + ctx.drawImage(video, 0, 0, width, height); + canvas.toBlob( + (blob) => { + finalize(blob ?? null); + }, + "image/jpeg", + 0.8, + ); + } catch { + finalize(null); + } + }, + { once: true }, + ); + }), + [], + ); + + const convertToMp4 = useCallback( + async (blob: Blob, hasAudio: boolean, currentVideoId: string) => { + updatePhase("converting"); + setUploadStatus({ + status: "converting", + capId: currentVideoId, + progress: 0, + }); + + const file = new File([blob], "recording.webm", { type: blob.type }); + const { convertMedia } = await import("@remotion/webcodecs"); + + const result = await convertMedia({ + src: file, + container: "mp4", + videoCodec: "h264", + ...(hasAudio ? { audioCodec: "aac" as const } : {}), + onProgress: ({ overallProgress }) => { + if (overallProgress !== null) { + const percent = Math.min(100, Math.max(0, overallProgress * 100)); + setUploadStatus({ + status: "converting", + capId: currentVideoId, + progress: percent, + }); + } + }, + }); + + const savedFile = await result.save(); + if (savedFile.size === 0) { + throw new Error("Conversion produced empty file"); + } + if (savedFile.type !== "video/mp4") { + return new File([savedFile], "result.mp4", { type: "video/mp4" }); + } + return savedFile; + }, + [updatePhase, setUploadStatus], + ); + + const uploadRecording = useCallback( + async ( + blob: Blob, + upload: PresignedPost, + currentVideoId: VideoId, + thumbnailPreviewUrl: string | undefined, + ) => + new Promise((resolve, reject) => { + if (blob.size === 0) { + reject(new Error("Cannot upload empty file")); + return; + } + + const fileBlob = + blob instanceof File && blob.type === "video/mp4" + ? blob + : new File([blob], "result.mp4", { type: "video/mp4" }); + + console.log("Uploading file:", { + size: fileBlob.size, + type: fileBlob.type, + name: fileBlob.name, + uploadUrl: upload.url, + uploadFields: upload.fields, + }); + + const formData = new FormData(); + Object.entries(upload.fields).forEach(([key, value]) => { + formData.append(key, value); + }); + formData.append("file", fileBlob, "result.mp4"); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", upload.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + setUploadStatus({ + status: "uploadingVideo", + capId: currentVideoId, + progress: percent, + thumbnailUrl: thumbnailPreviewUrl, + }); + sendProgressUpdate(currentVideoId, event.loaded, event.total); + } + }; + + xhr.onload = async () => { + if (xhr.status >= 200 && xhr.status < 300) { + await sendProgressUpdate(currentVideoId, blob.size, blob.size); + resolve(); + } else { + const errorText = + xhr.responseText || xhr.statusText || "Unknown error"; + console.error("Upload failed:", { + status: xhr.status, + statusText: xhr.statusText, + responseText: errorText, + }); + reject( + new Error( + `Upload failed with status ${xhr.status}: ${errorText}`, + ), + ); + } + }; + + xhr.onerror = () => { + reject(new Error("Upload failed due to network error")); + }; + + xhr.send(formData); + }), + [setUploadStatus], + ); + + const startRecording = async () => { + if (!organisationId) { + toast.error("Select an organization before recording."); + return; + } + + if (recordingMode === "camera" && !selectedCameraId) { + toast.error("Select a camera before recording."); + return; + } + + setIsSettingUp(true); + + try { + let videoStream: MediaStream | null = null; + let firstTrack: MediaStreamTrack | null = null; + + if (recordingMode === "camera") { + if (!selectedCameraId) { + throw new Error("Camera ID is required for camera-only mode"); + } + videoStream = await navigator.mediaDevices.getUserMedia({ + video: { + deviceId: { exact: selectedCameraId }, + frameRate: { ideal: 30 }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + }); + cameraStreamRef.current = videoStream; + firstTrack = videoStream.getVideoTracks()[0] ?? null; + } else { + const desiredSurface = + RECORDING_MODE_TO_DISPLAY_SURFACE[ + recordingMode as DetectedDisplayRecordingMode + ]; + const videoConstraints: MediaTrackConstraints & { + displaySurface?: DisplaySurfacePreference; + } = { + ...DISPLAY_MEDIA_VIDEO_CONSTRAINTS, + displaySurface: desiredSurface, + }; + + const baseDisplayRequest: ExtendedDisplayMediaStreamOptions = { + video: videoConstraints, + audio: false, + preferCurrentTab: recordingMode === "tab", + }; + + const preferredOptions = DISPLAY_MODE_PREFERENCES[recordingMode]; + + if (preferredOptions) { + const preferredDisplayRequest: DisplayMediaStreamOptions = { + ...baseDisplayRequest, + ...preferredOptions, + video: videoConstraints, + }; + + try { + videoStream = await navigator.mediaDevices.getDisplayMedia( + preferredDisplayRequest, + ); + } catch (displayError) { + if (shouldRetryDisplayMediaWithoutPreferences(displayError)) { + console.warn( + "Display media preferences not supported, retrying without them", + displayError, + ); + videoStream = + await navigator.mediaDevices.getDisplayMedia( + baseDisplayRequest, + ); + } else { + throw displayError; + } + } + } + + if (!videoStream) { + videoStream = + await navigator.mediaDevices.getDisplayMedia(baseDisplayRequest); + } + displayStreamRef.current = videoStream; + firstTrack = videoStream.getVideoTracks()[0] ?? null; + } + + const settings = firstTrack?.getSettings(); + + if (recordingMode !== "camera") { + scheduleSurfaceDetection(firstTrack, settings); + } + + dimensionsRef.current = { + width: settings?.width || undefined, + height: settings?.height || undefined, + }; + + let micStream: MediaStream | null = null; + if (micEnabled && selectedMicId) { + try { + micStream = await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId: { exact: selectedMicId }, + echoCancellation: true, + autoGainControl: true, + noiseSuppression: true, + }, + }); + } catch (micError) { + console.warn("Microphone permission denied", micError); + toast.warning("Microphone unavailable. Recording without audio."); + micStream = null; + } + } + + if (micStream) { + micStreamRef.current = micStream; + } + + const mixedStream = new MediaStream([ + ...videoStream.getVideoTracks(), + ...(micStream ? micStream.getAudioTracks() : []), + ]); + + mixedStreamRef.current = mixedStream; + setHasAudioTrack(mixedStream.getAudioTracks().length > 0); + + recordedChunksRef.current = []; + + const mimeTypeCandidates = [ + "video/webm;codecs=vp9,opus", + "video/webm;codecs=vp8,opus", + "video/webm", + ]; + const mimeType = mimeTypeCandidates.find((candidate) => + MediaRecorder.isTypeSupported(candidate), + ); + + const recorder = new MediaRecorder( + mixedStream, + mimeType ? { mimeType } : undefined, + ); + recorder.ondataavailable = onRecorderDataAvailable; + recorder.onstop = onRecorderStop; + recorder.onerror = onRecorderError; + + const handleVideoEnded = () => { + stopRecordingRef.current?.().catch(() => { + /* ignore */ + }); + }; + + firstTrack?.addEventListener("ended", handleVideoEnded, { once: true }); + + mediaRecorderRef.current = recorder; + recorder.start(200); + + startTimeRef.current = performance.now(); + setDurationMs(0); + updatePhase("recording"); + + timerRef.current = window.setInterval(() => { + if (startTimeRef.current !== null) + setDurationMs(performance.now() - startTimeRef.current); + }, 250); + } catch (err) { + console.error("Failed to start recording", err); + toast.error("Could not start recording."); + resetState(); + } finally { + setIsSettingUp(false); + } + }; + + const stopRecording = useCallback(async () => { + if (phase !== "recording") return; + + let createdVideoId: VideoId | null = null; + const orgId = organisationId; + if (!orgId) { + updatePhase("error"); + return; + } + + const brandedOrgId = Organisation.OrganisationId.make(orgId); + + let thumbnailBlob: Blob | null = null; + let thumbnailPreviewUrl: string | undefined; + + try { + updatePhase("creating"); + + const blob = await stopRecordingInternal(); + if (!blob) { + throw new Error("No recording available"); + } + + const durationSeconds = Math.max(1, Math.round(durationMs / 1000)); + const width = dimensionsRef.current.width; + const height = dimensionsRef.current.height; + const resolution = width && height ? `${width}x${height}` : undefined; + + setUploadStatus({ status: "creating" }); + + const result = await EffectRuntime.runPromise( + rpc.VideoInstantCreate({ + orgId: brandedOrgId, + folderId: Option.none(), + resolution, + durationSeconds, + width, + height, + videoCodec: "h264", + audioCodec: hasAudioTrack ? "aac" : undefined, + supportsUploadProgress: true, + }), + ); + + createdVideoId = result.id; + setVideoId(result.id); + + const mp4Blob = await convertToMp4(blob, hasAudioTrack, result.id); + + thumbnailBlob = await captureThumbnail(mp4Blob); + thumbnailPreviewUrl = thumbnailBlob + ? URL.createObjectURL(thumbnailBlob) + : undefined; + + updatePhase("uploading"); + setUploadStatus({ + status: "uploadingVideo", + capId: result.id, + progress: 0, + thumbnailUrl: thumbnailPreviewUrl, + }); + + await uploadRecording( + mp4Blob, + result.upload, + result.id, + thumbnailPreviewUrl, + ); + + if (thumbnailBlob) { + try { + const screenshotData = await createVideoAndGetUploadUrl({ + videoId: result.id, + isScreenshot: true, + orgId: brandedOrgId, + }); + + const screenshotFormData = new FormData(); + Object.entries(screenshotData.presignedPostData.fields).forEach( + ([key, value]) => { + screenshotFormData.append(key, value as string); + }, + ); + screenshotFormData.append( + "file", + thumbnailBlob, + "screen-capture.jpg", + ); + + setUploadStatus({ + status: "uploadingThumbnail", + capId: result.id, + progress: 90, + }); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", screenshotData.presignedPostData.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = 90 + (event.loaded / event.total) * 10; + setUploadStatus({ + status: "uploadingThumbnail", + capId: result.id, + progress: percent, + }); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject( + new Error( + `Screenshot upload failed with status ${xhr.status}`, + ), + ); + } + }; + + xhr.onerror = () => { + reject(new Error("Screenshot upload failed")); + }; + + xhr.send(screenshotFormData); + }); + + queryClient.refetchQueries({ + queryKey: ThumbnailRequest.queryKey(result.id), + }); + } catch (thumbnailError) { + console.error("Failed to upload thumbnail", thumbnailError); + toast.warning("Recording uploaded, but thumbnail failed to upload."); + } + } + + setUploadStatus(undefined); + updatePhase("completed"); + toast.success("Recording uploaded"); + router.refresh(); + } catch (err) { + console.error("Failed to process recording", err); + setUploadStatus(undefined); + updatePhase("error"); + + const idToDelete = createdVideoId ?? videoId; + if (idToDelete) { + EffectRuntime.runPromise(rpc.VideoDelete(idToDelete)).catch(() => { + /* ignore */ + }); + } + } finally { + if (thumbnailPreviewUrl) { + URL.revokeObjectURL(thumbnailPreviewUrl); + } + } + }, [ + phase, + organisationId, + durationMs, + hasAudioTrack, + videoId, + updatePhase, + setUploadStatus, + rpc, + router, + convertToMp4, + uploadRecording, + stopRecordingInternal, + captureThumbnail, + queryClient, + ]); + + useEffect(() => { + stopRecordingRef.current = stopRecording; + }, [stopRecording]); + + return { + phase, + durationMs, + videoId, + hasAudioTrack, + isSettingUp, + isRecording: phase === "recording", + isBusy: + phase === "recording" || + phase === "creating" || + phase === "converting" || + phase === "uploading", + canStartRecording: Boolean(organisationId) && !isSettingUp, + startRecording, + stopRecording, + resetState, + }; }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-constants.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-constants.ts index 52a83e7d9a..46cd56a8fd 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-constants.ts +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-constants.ts @@ -4,29 +4,28 @@ export const NO_CAMERA = "No Camera"; export const NO_CAMERA_VALUE = "__no_camera__"; export const dialogVariants = { - hidden: { - opacity: 0, - scale: 0.9, - y: 20, - }, - visible: { - opacity: 1, - scale: 1, - y: 0, - transition: { - type: "spring", - duration: 0.4, - damping: 25, - stiffness: 500, - }, - }, - exit: { - opacity: 0, - scale: 0.95, - y: 10, - transition: { - duration: 0.2, - }, - }, + hidden: { + opacity: 0, + scale: 0.9, + y: 20, + }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: "spring", + duration: 0.4, + damping: 25, + stiffness: 500, + }, + }, + exit: { + opacity: 0, + scale: 0.95, + y: 10, + transition: { + duration: 0.2, + }, + }, }; - diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-types.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-types.ts index 9675401db2..1f8f56c960 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-types.ts +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/web-recorder-types.ts @@ -1,15 +1,14 @@ export type RecorderPhase = - | "idle" - | "recording" - | "creating" - | "converting" - | "uploading" - | "completed" - | "error"; + | "idle" + | "recording" + | "creating" + | "converting" + | "uploading" + | "completed" + | "error"; export type RecorderErrorEvent = Event & { error?: DOMException }; type VideoNamespace = typeof import("@cap/web-domain").Video; export type PresignedPost = VideoNamespace["PresignedPost"]["Type"]; export type VideoId = VideoNamespace["VideoId"]["Type"]; - diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index a7880e135a..dc4aa4de8d 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -15,10 +15,7 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( "DatabaseError", () => new InternalError({ type: "database" }), ), - Effect.catchTag( - "S3Error", - () => new InternalError({ type: "s3" }), - ), + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), ), VideoDuplicate: (videoId) => @@ -27,10 +24,7 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( "DatabaseError", () => new InternalError({ type: "database" }), ), - Effect.catchTag( - "S3Error", - () => new InternalError({ type: "s3" }), - ), + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), ), GetUploadProgress: (videoId) => @@ -52,19 +46,18 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( "DatabaseError", () => new InternalError({ type: "database" }), ), - Effect.catchTag( - "S3Error", - () => new InternalError({ type: "s3" }), - ), + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), ), VideoUploadProgressUpdate: (input) => - videos.updateUploadProgress(input).pipe( - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), + videos + .updateUploadProgress(input) + .pipe( + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), ), - ), VideoGetDownloadInfo: (videoId) => videos.getDownloadInfo(videoId).pipe( @@ -77,10 +70,7 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( "UnknownException", () => new InternalError({ type: "unknown" }), ), - Effect.catchTag( - "S3Error", - () => new InternalError({ type: "s3" }), - ), + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), ), VideosGetThumbnails: (videoIds) => diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index 6951af4709..a5b0d5feca 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -1,7 +1,7 @@ import * as Db from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub } from "@cap/utils"; -import { CurrentUser, Folder, Policy, Video } from "@cap/web-domain"; +import { CurrentUser, type Folder, Policy, Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Array, Context, Effect, Option, pipe } from "effect"; import type { Schema } from "effect/Schema"; @@ -9,8 +9,8 @@ import type { Schema } from "effect/Schema"; import { Database } from "../Database.ts"; import { S3Buckets } from "../S3Buckets/index.ts"; import { VideosPolicy } from "./VideosPolicy.ts"; -import { VideosRepo } from "./VideosRepo.ts"; import type { CreateVideoInput as RepoCreateVideoInput } from "./VideosRepo.ts"; +import { VideosRepo } from "./VideosRepo.ts"; type UploadProgressUpdateInput = Schema.Type< typeof Video.UploadProgressUpdateInput @@ -145,19 +145,17 @@ export class Videos extends Effect.Service()("Videos", { const [record] = yield* db .use((db) => - db - .select({ - video: Db.videos, - upload: Db.videoUploads, - }) - .from(Db.videos) - .leftJoin( - Db.videoUploads, - Dz.eq(Db.videos.id, Db.videoUploads.videoId), - ) - .where( - Dz.eq(Db.videos.id, videoId), - ), + db + .select({ + video: Db.videos, + upload: Db.videoUploads, + }) + .from(Db.videos) + .leftJoin( + Db.videoUploads, + Dz.eq(Db.videos.id, Db.videoUploads.videoId), + ) + .where(Dz.eq(Db.videos.id, videoId)), ) .pipe(Policy.withPolicy(policy.isOwner(videoId))); @@ -203,7 +201,7 @@ export class Videos extends Effect.Service()("Videos", { createInstantRecording: Effect.fn("Videos.createInstantRecording")( function* (input: InstantRecordingCreateInput) { - const user = yield* CurrentUser; + const user = yield* CurrentUser; if (user.activeOrganizationId !== input.orgId) return yield* Effect.fail(new Policy.PolicyDeniedError()); @@ -222,25 +220,28 @@ export class Videos extends Effect.Service()("Videos", { const duration = Option.fromNullable(input.durationSeconds); const now = new Date(); - const formattedDate = `${now.getDate()} ${now.toLocaleString("default", { - month: "long", - })} ${now.getFullYear()}`; - - const createData = { - ownerId: user.id, - orgId: input.orgId, - name: `Cap Recording - ${formattedDate}`, - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - source: { type: "desktopMP4" as const }, - bucketId, - folderId, - width, - height, - duration, - metadata: Option.none(), - transcriptionStatus: Option.none(), - } as unknown as RepoCreateVideoInput; - const videoId = yield* repo.create(createData); + const formattedDate = `${now.getDate()} ${now.toLocaleString( + "default", + { + month: "long", + }, + )} ${now.getFullYear()}`; + + const createData = { + ownerId: user.id, + orgId: input.orgId, + name: `Cap Recording - ${formattedDate}`, + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + source: { type: "desktopMP4" as const }, + bucketId, + folderId, + width, + height, + duration, + metadata: Option.none(), + transcriptionStatus: Option.none(), + } as unknown as RepoCreateVideoInput; + const videoId = yield* repo.create(createData); if (input.supportsUploadProgress ?? true) yield* db.use((db) => @@ -252,38 +253,32 @@ export class Videos extends Effect.Service()("Videos", { const fileKey = `${user.id}/${videoId}/result.mp4`; const [bucket] = yield* s3Buckets.getBucketAccess(bucketId); - const presignedPostData = yield* bucket.getPresignedPostUrl( - fileKey, - { - Fields: { - "Content-Type": "video/mp4", - "x-amz-meta-userid": user.id, - "x-amz-meta-duration": input.durationSeconds - ? input.durationSeconds.toString() - : "", - "x-amz-meta-resolution": input.resolution ?? "", - "x-amz-meta-videocodec": input.videoCodec ?? "", - "x-amz-meta-audiocodec": input.audioCodec ?? "", - }, - Expires: 1800, + const presignedPostData = yield* bucket.getPresignedPostUrl(fileKey, { + Fields: { + "Content-Type": "video/mp4", + "x-amz-meta-userid": user.id, + "x-amz-meta-duration": input.durationSeconds + ? input.durationSeconds.toString() + : "", + "x-amz-meta-resolution": input.resolution ?? "", + "x-amz-meta-videocodec": input.videoCodec ?? "", + "x-amz-meta-audiocodec": input.audioCodec ?? "", }, - ); + Expires: 1800, + }); const shareUrl = `${serverEnv().WEB_URL}/s/${videoId}`; if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") yield* Effect.tryPromise(() => - dub() - .links.create({ - url: shareUrl, - domain: "cap.link", - key: videoId, - }), + dub().links.create({ + url: shareUrl, + domain: "cap.link", + key: videoId, + }), ).pipe( Effect.catchAll((error) => - Effect.logWarning( - `Dub link create failed: ${String(error)}`, - ), + Effect.logWarning(`Dub link create failed: ${String(error)}`), ), ); diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 8247836341..83b83cff75 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -195,11 +195,7 @@ export class VideoRpcs extends RpcGroup.make( Rpc.make("VideoUploadProgressUpdate", { payload: UploadProgressUpdateInput, success: Schema.Boolean, - error: Schema.Union( - NotFoundError, - InternalError, - PolicyDeniedError, - ), + error: Schema.Union(NotFoundError, InternalError, PolicyDeniedError), }).middleware(RpcAuthMiddleware), Rpc.make("VideoGetDownloadInfo", { payload: VideoId, From faaca088338a1bb94b22a42e9ebb8bc881ce650c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 6 Nov 2025 23:52:34 +0000 Subject: [PATCH 11/56] feat: component cleanup / division --- .../WebRecorderDialog/HowItWorksButton.tsx | 20 + .../WebRecorderDialog/HowItWorksPanel.tsx | 104 +++ .../WebRecorderDialog/SettingsButton.tsx | 27 + .../WebRecorderDialog/SettingsPanel.tsx | 67 ++ .../WebRecorderDialog/WebRecorderDialog.tsx | 787 +++++------------- .../WebRecorderDialog/useDevicePreferences.ts | 154 ++++ .../useDialogInteractions.ts | 120 +++ 7 files changed, 713 insertions(+), 566 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/HowItWorksButton.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/HowItWorksPanel.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/SettingsButton.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/SettingsPanel.tsx create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useDevicePreferences.ts create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useDialogInteractions.ts diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/HowItWorksButton.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/HowItWorksButton.tsx new file mode 100644 index 0000000000..9b63828c50 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/HowItWorksButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { CircleHelpIcon } from "lucide-react"; + +interface HowItWorksButtonProps { + onClick: () => void; +} + +export const HowItWorksButton = ({ onClick }: HowItWorksButtonProps) => { + return ( + + ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/HowItWorksPanel.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/HowItWorksPanel.tsx new file mode 100644 index 0000000000..a8b1af59d1 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/HowItWorksPanel.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { + ArrowLeftIcon, + CloudUploadIcon, + LinkIcon, + PictureInPictureIcon, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +const HOW_IT_WORKS_ITEMS = [ + { + title: "Uploads while you record", + description: + "On compatible browsers, your capture uploads in the background while you record. Otherwise, it records first and uploads right after you stop.", + Icon: CloudUploadIcon, + accent: "bg-blue-3 text-blue-11 dark:bg-blue-4 dark:text-blue-10", + }, + { + title: "Instant shareable link", + description: + "Stopping the recording finalizes the upload immediately so you can copy your link right away.", + Icon: LinkIcon, + accent: "bg-green-3 text-green-11 dark:bg-green-4 dark:text-green-10", + }, + { + title: "Keep your webcam visible", + description: + "On compatible browsers, selecting a camera opens a picture‑in‑picture window that's captured when you record fullscreen. We recommend fullscreen to keep it on top. If PiP capture isn't supported, your camera is limited to the Cap recorder page.", + Icon: PictureInPictureIcon, + accent: "bg-purple-3 text-purple-11 dark:bg-purple-4 dark:text-purple-10", + }, +] as const satisfies Array<{ + title: string; + description: string; + Icon: LucideIcon; + accent: string; +}>; + +interface HowItWorksPanelProps { + open: boolean; + onClose: () => void; +} + +export const HowItWorksPanel = ({ open, onClose }: HowItWorksPanelProps) => { + return ( + + {open && ( + +
+ +

+ How it works +

+ +
+
+
+ {HOW_IT_WORKS_ITEMS.map( + ({ title, description, Icon, accent }) => ( +
+
+
+ +
+
+

+ {title} +

+

+ {description} +

+
+
+
+ ) + )} +
+
+
+ )} +
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/SettingsButton.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/SettingsButton.tsx new file mode 100644 index 0000000000..887585608e --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/SettingsButton.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Button } from "@cap/ui"; +import CogIcon from "@/app/(org)/dashboard/_components/AnimatedIcons/Cog"; + +interface SettingsButtonProps { + visible: boolean; + onClick: () => void; +} + +export const SettingsButton = ({ visible, onClick }: SettingsButtonProps) => { + if (!visible) return null; + + return ( + + ); +}; + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/SettingsPanel.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/SettingsPanel.tsx new file mode 100644 index 0000000000..5df79b955d --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/SettingsPanel.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Switch } from "@cap/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowLeftIcon } from "lucide-react"; + +interface SettingsPanelProps { + open: boolean; + rememberDevices: boolean; + onClose: () => void; + onRememberDevicesChange: (value: boolean) => void; +} + +export const SettingsPanel = ({ + open, + rememberDevices, + onClose, + onRememberDevicesChange, +}: SettingsPanelProps) => { + return ( + + {open && ( + +
+ +

+ Recorder settings +

+ +
+
+
+
+

+ Automatically select your last webcam/microphone +

+

+ If available, the last used camera and mic will be + automatically selected. +

+
+ +
+
+
+ )} +
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx index c80a4bc901..32badf6b9e 100644 --- a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/WebRecorderDialog.tsx @@ -1,588 +1,243 @@ "use client"; import { - Button, - Dialog, - DialogContent, - DialogTitle, - DialogTrigger, - Switch, + Button, + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, } from "@cap/ui"; import { AnimatePresence, motion } from "framer-motion"; -import { - ArrowLeftIcon, - CircleHelpIcon, - CloudUploadIcon, - LinkIcon, - MonitorIcon, - PictureInPictureIcon, -} from "lucide-react"; +import { MonitorIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import CogIcon from "@/app/(org)/dashboard/_components/AnimatedIcons/Cog"; import { useDashboardContext } from "../../../Contexts"; import { CameraPreviewWindow } from "./CameraPreviewWindow"; import { CameraSelector } from "./CameraSelector"; +import { HowItWorksButton } from "./HowItWorksButton"; +import { HowItWorksPanel } from "./HowItWorksPanel"; import { MicrophoneSelector } from "./MicrophoneSelector"; import { RecordingButton } from "./RecordingButton"; import { - type RecordingMode, - RecordingModeSelector, + type RecordingMode, + RecordingModeSelector, } from "./RecordingModeSelector"; +import { SettingsButton } from "./SettingsButton"; +import { SettingsPanel } from "./SettingsPanel"; import { useCameraDevices } from "./useCameraDevices"; +import { useDevicePreferences } from "./useDevicePreferences"; +import { useDialogInteractions } from "./useDialogInteractions"; import { useMicrophoneDevices } from "./useMicrophoneDevices"; import { useWebRecorder } from "./useWebRecorder"; import { WebRecorderDialogHeader } from "./WebRecorderDialogHeader"; import { dialogVariants } from "./web-recorder-constants"; -const REMEMBER_DEVICES_KEY = "cap-web-recorder-remember-devices"; -const PREFERRED_CAMERA_KEY = "cap-web-recorder-preferred-camera"; -const PREFERRED_MICROPHONE_KEY = "cap-web-recorder-preferred-microphone"; -const HOW_IT_WORKS_ITEMS = [ - { - title: "Uploads while you record", - description: - "On compatible browsers, your capture uploads in the background while you record. Otherwise, it records first and uploads right after you stop.", - Icon: CloudUploadIcon, - accent: "bg-blue-3 text-blue-11 dark:bg-blue-4 dark:text-blue-10", - }, - { - title: "Instant shareable link", - description: - "Stopping the recording finalizes the upload immediately so you can copy your link right away.", - Icon: LinkIcon, - accent: "bg-green-3 text-green-11 dark:bg-green-4 dark:text-green-10", - }, - { - title: "Keep your webcam visible", - description: - "On compatible browsers, selecting a camera opens a picture‑in‑picture window that’s captured when you record fullscreen. We recommend fullscreen to keep it on top. If PiP capture isn’t supported, your camera is limited to the Cap recorder page.", - Icon: PictureInPictureIcon, - accent: "bg-purple-3 text-purple-11 dark:bg-purple-4 dark:text-purple-10", - }, -] as const; - export const WebRecorderDialog = () => { - const [open, setOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - const [howItWorksOpen, setHowItWorksOpen] = useState(false); - const [selectedMicId, setSelectedMicId] = useState(null); - const [recordingMode, setRecordingMode] = - useState("fullscreen"); - const [selectedCameraId, setSelectedCameraId] = useState(null); - const [cameraSelectOpen, setCameraSelectOpen] = useState(false); - const [micSelectOpen, setMicSelectOpen] = useState(false); - const [rememberDevices, setRememberDevices] = useState(false); - const dialogContentRef = useRef(null); - - const { activeOrganization } = useDashboardContext(); - const organisationId = activeOrganization?.organization.id; - const { devices: availableMics, refresh: refreshMics } = - useMicrophoneDevices(open); - const { devices: availableCameras, refresh: refreshCameras } = - useCameraDevices(open); - - const micEnabled = selectedMicId !== null; - - useEffect(() => { - if ( - recordingMode === "camera" && - !selectedCameraId && - availableCameras.length > 0 - ) { - setSelectedCameraId(availableCameras[0]?.deviceId ?? null); - } - }, [recordingMode, selectedCameraId, availableCameras]); - - useEffect(() => { - if (typeof window === "undefined") return; - - try { - const storedRemember = window.localStorage.getItem(REMEMBER_DEVICES_KEY); - if (storedRemember === "true") { - setRememberDevices(true); - } - } catch (error) { - console.error("Failed to load recorder preferences", error); - } - }, []); - - useEffect(() => { - if (!open || !rememberDevices) return; - if (typeof window === "undefined") return; - - try { - const storedCameraId = window.localStorage.getItem(PREFERRED_CAMERA_KEY); - if (storedCameraId) { - const hasCamera = availableCameras.some( - (camera) => camera.deviceId === storedCameraId, - ); - if (hasCamera && storedCameraId !== selectedCameraId) { - setSelectedCameraId(storedCameraId); - } - } - - const storedMicId = window.localStorage.getItem(PREFERRED_MICROPHONE_KEY); - if (storedMicId) { - const hasMic = availableMics.some( - (microphone) => microphone.deviceId === storedMicId, - ); - if (hasMic && storedMicId !== selectedMicId) { - setSelectedMicId(storedMicId); - } - } - } catch (error) { - console.error("Failed to restore recorder device selection", error); - } - }, [ - open, - rememberDevices, - availableCameras, - availableMics, - selectedCameraId, - selectedMicId, - ]); - - const { - isRecording, - isBusy, - canStartRecording, - startRecording, - stopRecording, - resetState, - } = useWebRecorder({ - organisationId, - selectedMicId, - micEnabled, - recordingMode, - selectedCameraId, - onRecordingSurfaceDetected: (mode) => { - setRecordingMode(mode); - }, - }); - - const handleOpenChange = (next: boolean) => { - if (!next && isBusy) { - toast.info("Keep this dialog open while your upload finishes."); - return; - } - - if (!next) { - resetState(); - setSelectedCameraId(null); - setRecordingMode("fullscreen"); - setSettingsOpen(false); - setHowItWorksOpen(false); - } - setOpen(next); - }; - - const handleStopClick = () => { - stopRecording().catch((err: unknown) => { - console.error("Stop recording error", err); - }); - }; - - const handleClose = () => { - if (!isBusy) { - handleOpenChange(false); - } - }; - - const handleCameraChange = (cameraId: string | null) => { - setSelectedCameraId(cameraId); - - if (!rememberDevices || typeof window === "undefined") { - return; - } - - try { - if (cameraId) { - window.localStorage.setItem(PREFERRED_CAMERA_KEY, cameraId); - } else { - window.localStorage.removeItem(PREFERRED_CAMERA_KEY); - } - } catch (error) { - console.error("Failed to persist preferred camera", error); - } - }; - - const handleMicChange = (micId: string | null) => { - setSelectedMicId(micId); - - if (!rememberDevices || typeof window === "undefined") { - return; - } - - try { - if (micId) { - window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, micId); - } else { - window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); - } - } catch (error) { - console.error("Failed to persist preferred microphone", error); - } - }; - - const handleRememberDevicesChange = (next: boolean) => { - setRememberDevices(next); - - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem( - REMEMBER_DEVICES_KEY, - next ? "true" : "false", - ); - - if (next) { - if (selectedCameraId) { - window.localStorage.setItem(PREFERRED_CAMERA_KEY, selectedCameraId); - } else { - window.localStorage.removeItem(PREFERRED_CAMERA_KEY); - } - - if (selectedMicId) { - window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, selectedMicId); - } else { - window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); - } - } else { - window.localStorage.removeItem(PREFERRED_CAMERA_KEY); - window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); - } - } catch (error) { - console.error("Failed to update recorder preferences", error); - } - }; - - return ( - <> - - - - - { - const originalEvent = event.detail.originalEvent as - | PointerEvent - | undefined; - const target = originalEvent?.target as Element | undefined; - - if (!target) return; - - if (isRecording || isBusy) { - event.preventDefault(); - return; - } - - const path = originalEvent?.composedPath() || []; - const dialogContent = dialogContentRef.current; - - const isInsideDialog = (el: Element) => { - if (!dialogContent) return false; - return dialogContent.contains(el); - }; - - const isWhitelisted = (el: Element) => { - if (isInsideDialog(el)) return true; - if (el.closest('[data-slot="select-content"]')) return true; - if (el.closest("[data-radix-select-content]")) return true; - if (el.closest("[data-radix-select-viewport]")) return true; - if (el.closest("[data-radix-select-item]")) return true; - if (el.closest("[data-camera-preview]")) return true; - return false; - }; - - if ( - (target && isWhitelisted(target)) || - path.some( - (t) => t instanceof Element && isWhitelisted(t as Element), - ) - ) { - event.preventDefault(); - } - }} - onFocusOutside={(event) => { - const target = event.target as Element | undefined; - - if (!target) return; - - if (isRecording || isBusy) { - event.preventDefault(); - return; - } - - const path = - (event.detail?.originalEvent as FocusEvent)?.composedPath?.() || - []; - const dialogContent = dialogContentRef.current; - - const isInsideDialog = (el: Element) => { - if (!dialogContent) return false; - return dialogContent.contains(el); - }; - - const isWhitelisted = (el: Element) => { - if (isInsideDialog(el)) return true; - if (el.closest('[data-slot="select-content"]')) return true; - if (el.closest("[data-radix-select-content]")) return true; - if (el.closest("[data-radix-select-viewport]")) return true; - if (el.closest("[data-radix-select-item]")) return true; - if (el.closest("[data-camera-preview]")) return true; - return false; - }; - - if ( - (target && isWhitelisted(target)) || - path.some( - (t) => t instanceof Element && isWhitelisted(t as Element), - ) - ) { - event.preventDefault(); - } - }} - onInteractOutside={(event) => { - const originalEvent = event.detail.originalEvent as - | Event - | undefined; - const target = originalEvent?.target as Element | undefined; - - if (!target) return; - - if (isRecording || isBusy) { - event.preventDefault(); - return; - } - - const path = originalEvent?.composedPath?.() || []; - const dialogContent = dialogContentRef.current; - - const isInsideDialog = (el: Element) => { - if (!dialogContent) return false; - return dialogContent.contains(el); - }; - - const isWhitelisted = (el: Element) => { - if (isInsideDialog(el)) return true; - if (el.closest('[data-slot="select-content"]')) return true; - if (el.closest("[data-radix-select-content]")) return true; - if (el.closest("[data-radix-select-viewport]")) return true; - if (el.closest("[data-radix-select-item]")) return true; - if (el.closest("[data-camera-preview]")) return true; - return false; - }; - - if ( - (target && isWhitelisted(target)) || - path.some( - (t) => t instanceof Element && isWhitelisted(t as Element), - ) - ) { - event.preventDefault(); - } - }} - > - Instant Mode Recorder - - {open && ( - - {!settingsOpen && ( - - )} - - {settingsOpen && ( - -
- -

- Recorder settings -

- -
-
-
-
-

- Automatically select your last webcam/microphone -

-

- If available, the last used camera and mic will be - automatically selected. -

-
- -
-
-
- )} -
- - {howItWorksOpen && ( - -
- -

- How it works -

- -
-
-
- {HOW_IT_WORKS_ITEMS.map( - ({ title, description, Icon, accent }) => ( -
-
-
- -
-
-

- {title} -

-

- {description} -

-
-
-
- ), - )} -
-
-
- )} -
- - - { - setCameraSelectOpen(isOpen); - if (isOpen) { - setMicSelectOpen(false); - } - }} - onCameraChange={handleCameraChange} - onRefreshDevices={refreshCameras} - /> - { - setMicSelectOpen(isOpen); - if (isOpen) { - setCameraSelectOpen(false); - } - }} - onMicChange={handleMicChange} - onRefreshDevices={refreshMics} - /> - - -
- )} -
-
-
- {selectedCameraId && ( - handleCameraChange(null)} - /> - )} - - ); + const [open, setOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [howItWorksOpen, setHowItWorksOpen] = useState(false); + const [recordingMode, setRecordingMode] = + useState("fullscreen"); + const [cameraSelectOpen, setCameraSelectOpen] = useState(false); + const [micSelectOpen, setMicSelectOpen] = useState(false); + const dialogContentRef = useRef(null); + + const { activeOrganization } = useDashboardContext(); + const organisationId = activeOrganization?.organization.id; + const { devices: availableMics, refresh: refreshMics } = + useMicrophoneDevices(open); + const { devices: availableCameras, refresh: refreshCameras } = + useCameraDevices(open); + + const { + rememberDevices, + selectedCameraId, + selectedMicId, + setSelectedCameraId, + handleCameraChange, + handleMicChange, + handleRememberDevicesChange, + } = useDevicePreferences({ + open, + availableCameras, + availableMics, + }); + + const micEnabled = selectedMicId !== null; + + useEffect(() => { + if ( + recordingMode === "camera" && + !selectedCameraId && + availableCameras.length > 0 + ) { + setSelectedCameraId(availableCameras[0]?.deviceId ?? null); + } + }, [recordingMode, selectedCameraId, availableCameras, setSelectedCameraId]); + + const { + isRecording, + isBusy, + canStartRecording, + startRecording, + stopRecording, + resetState, + } = useWebRecorder({ + organisationId, + selectedMicId, + micEnabled, + recordingMode, + selectedCameraId, + onRecordingSurfaceDetected: (mode) => { + setRecordingMode(mode); + }, + }); + + const { + handlePointerDownOutside, + handleFocusOutside, + handleInteractOutside, + } = useDialogInteractions({ + dialogContentRef, + isRecording, + isBusy, + }); + + const handleOpenChange = (next: boolean) => { + if (!next && isBusy) { + toast.info("Keep this dialog open while your upload finishes."); + return; + } + + if (!next) { + resetState(); + setSelectedCameraId(null); + setRecordingMode("fullscreen"); + setSettingsOpen(false); + setHowItWorksOpen(false); + } + setOpen(next); + }; + + const handleStopClick = () => { + stopRecording().catch((err: unknown) => { + console.error("Stop recording error", err); + }); + }; + + const handleClose = () => { + if (!isBusy) { + handleOpenChange(false); + } + }; + + const handleSettingsOpen = () => { + setSettingsOpen(true); + setHowItWorksOpen(false); + }; + + const handleHowItWorksOpen = () => { + setHowItWorksOpen(true); + setSettingsOpen(false); + }; + + return ( + <> + + + + + + Instant Mode Recorder + + {open && ( + + + setSettingsOpen(false)} + onRememberDevicesChange={handleRememberDevicesChange} + /> + setHowItWorksOpen(false)} + /> + + + { + setCameraSelectOpen(isOpen); + if (isOpen) { + setMicSelectOpen(false); + } + }} + onCameraChange={handleCameraChange} + onRefreshDevices={refreshCameras} + /> + { + setMicSelectOpen(isOpen); + if (isOpen) { + setCameraSelectOpen(false); + } + }} + onMicChange={handleMicChange} + onRefreshDevices={refreshMics} + /> + + + + )} + + + + {selectedCameraId && ( + handleCameraChange(null)} + /> + )} + + ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useDevicePreferences.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useDevicePreferences.ts new file mode 100644 index 0000000000..309be0882d --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useDevicePreferences.ts @@ -0,0 +1,154 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const REMEMBER_DEVICES_KEY = "cap-web-recorder-remember-devices"; +const PREFERRED_CAMERA_KEY = "cap-web-recorder-preferred-camera"; +const PREFERRED_MICROPHONE_KEY = "cap-web-recorder-preferred-microphone"; + +interface DevicePreferencesOptions { + open: boolean; + availableCameras: Array<{ deviceId: string }>; + availableMics: Array<{ deviceId: string }>; +} + +export const useDevicePreferences = ({ + open, + availableCameras, + availableMics, +}: DevicePreferencesOptions) => { + const [rememberDevices, setRememberDevices] = useState(false); + const [selectedCameraId, setSelectedCameraId] = useState(null); + const [selectedMicId, setSelectedMicId] = useState(null); + + useEffect(() => { + if (typeof window === "undefined") return; + + try { + const storedRemember = window.localStorage.getItem(REMEMBER_DEVICES_KEY); + if (storedRemember === "true") { + setRememberDevices(true); + } + } catch (error) { + console.error("Failed to load recorder preferences", error); + } + }, []); + + useEffect(() => { + if (!open || !rememberDevices) return; + if (typeof window === "undefined") return; + + try { + const storedCameraId = window.localStorage.getItem(PREFERRED_CAMERA_KEY); + if (storedCameraId) { + const hasCamera = availableCameras.some( + (camera) => camera.deviceId === storedCameraId, + ); + if (hasCamera && storedCameraId !== selectedCameraId) { + setSelectedCameraId(storedCameraId); + } + } + + const storedMicId = window.localStorage.getItem(PREFERRED_MICROPHONE_KEY); + if (storedMicId) { + const hasMic = availableMics.some( + (microphone) => microphone.deviceId === storedMicId, + ); + if (hasMic && storedMicId !== selectedMicId) { + setSelectedMicId(storedMicId); + } + } + } catch (error) { + console.error("Failed to restore recorder device selection", error); + } + }, [ + open, + rememberDevices, + availableCameras, + availableMics, + selectedCameraId, + selectedMicId, + ]); + + const handleCameraChange = (cameraId: string | null) => { + setSelectedCameraId(cameraId); + + if (!rememberDevices || typeof window === "undefined") { + return; + } + + try { + if (cameraId) { + window.localStorage.setItem(PREFERRED_CAMERA_KEY, cameraId); + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + } + } catch (error) { + console.error("Failed to persist preferred camera", error); + } + }; + + const handleMicChange = (micId: string | null) => { + setSelectedMicId(micId); + + if (!rememberDevices || typeof window === "undefined") { + return; + } + + try { + if (micId) { + window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, micId); + } else { + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } catch (error) { + console.error("Failed to persist preferred microphone", error); + } + }; + + const handleRememberDevicesChange = (next: boolean) => { + setRememberDevices(next); + + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + REMEMBER_DEVICES_KEY, + next ? "true" : "false", + ); + + if (next) { + if (selectedCameraId) { + window.localStorage.setItem(PREFERRED_CAMERA_KEY, selectedCameraId); + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + } + + if (selectedMicId) { + window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, selectedMicId); + } else { + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } else { + window.localStorage.removeItem(PREFERRED_CAMERA_KEY); + window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY); + } + } catch (error) { + console.error("Failed to update recorder preferences", error); + } + }; + + return { + rememberDevices, + selectedCameraId, + selectedMicId, + setSelectedCameraId, + setSelectedMicId, + handleCameraChange, + handleMicChange, + handleRememberDevicesChange, + }; +}; + diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useDialogInteractions.ts b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useDialogInteractions.ts new file mode 100644 index 0000000000..84c994051e --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/useDialogInteractions.ts @@ -0,0 +1,120 @@ +"use client"; + +import type * as DialogPrimitive from "@radix-ui/react-dialog"; +import { type RefObject } from "react"; + +const isInsideDialog = (el: Element, dialogContent: HTMLElement | null) => { + if (!dialogContent) return false; + return dialogContent.contains(el); +}; + +const isWhitelisted = (el: Element, dialogContent: HTMLElement | null) => { + if (isInsideDialog(el, dialogContent)) return true; + if (el.closest('[data-slot="select-content"]')) return true; + if (el.closest("[data-radix-select-content]")) return true; + if (el.closest("[data-radix-select-viewport]")) return true; + if (el.closest("[data-radix-select-item]")) return true; + if (el.closest("[data-camera-preview]")) return true; + return false; +}; + +const shouldPreventDefault = ( + target: Element | null | undefined, + path: Array, + dialogContent: HTMLElement | null, +) => { + if (!target) return false; + + return ( + isWhitelisted(target, dialogContent) || + path.some((t) => t instanceof Element && isWhitelisted(t as Element, dialogContent)) + ); +}; + +interface UseDialogInteractionsOptions { + dialogContentRef: RefObject; + isRecording: boolean; + isBusy: boolean; +} + +export const useDialogInteractions = ({ + dialogContentRef, + isRecording, + isBusy, +}: UseDialogInteractionsOptions) => { + const handlePointerDownOutside = ( + event: DialogPrimitive.DialogContentProps["onPointerDownOutside"], + ) => { + if (!event) return; + + const originalEvent = event.detail.originalEvent; + const target = originalEvent?.target as Element | null | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = originalEvent?.composedPath() || []; + const dialogContent = dialogContentRef.current; + + if (shouldPreventDefault(target, path, dialogContent)) { + event.preventDefault(); + } + }; + + const handleFocusOutside = ( + event: DialogPrimitive.DialogContentProps["onFocusOutside"], + ) => { + if (!event) return; + + const target = event.target as Element | null | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = + (event.detail?.originalEvent as FocusEvent)?.composedPath?.() || []; + const dialogContent = dialogContentRef.current; + + if (shouldPreventDefault(target, path, dialogContent)) { + event.preventDefault(); + } + }; + + const handleInteractOutside = ( + event: DialogPrimitive.DialogContentProps["onInteractOutside"], + ) => { + if (!event) return; + + const originalEvent = event.detail.originalEvent; + const target = originalEvent?.target as Element | null | undefined; + + if (!target) return; + + if (isRecording || isBusy) { + event.preventDefault(); + return; + } + + const path = originalEvent?.composedPath?.() || []; + const dialogContent = dialogContentRef.current; + + if (shouldPreventDefault(target, path, dialogContent)) { + event.preventDefault(); + } + }; + + return { + handlePointerDownOutside, + handleFocusOutside, + handleInteractOutside, + }; +}; + From fe680cf40bd02f19e291a0a47e74d572b53070a0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 7 Nov 2025 00:03:41 +0000 Subject: [PATCH 12/56] feat: In progress recording bar --- .../InProgressRecordingBar.tsx | 264 ++++++++++++++++++ .../WebRecorderDialog/WebRecorderDialog.tsx | 14 + 2 files changed, 278 insertions(+) create mode 100644 apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/InProgressRecordingBar.tsx diff --git a/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/InProgressRecordingBar.tsx b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/InProgressRecordingBar.tsx new file mode 100644 index 0000000000..089015f619 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/WebRecorderDialog/InProgressRecordingBar.tsx @@ -0,0 +1,264 @@ +"use client"; + +import clsx from "clsx"; +import { + Mic, + MicOff, + MoreVertical, + PauseCircle, + RotateCcw, + StopCircle, +} from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ComponentProps, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { createPortal } from "react-dom"; +import type { RecorderPhase } from "./web-recorder-types"; + +const phaseMessages: Partial> = { + recording: "Recording", + creating: "Finishing up", + converting: "Converting", + uploading: "Uploading", +}; + +const clamp = (value: number, min: number, max: number) => { + if (Number.isNaN(value)) return min; + if (max < min) return min; + return Math.min(Math.max(value, min), max); +}; + +const formatDuration = (durationMs: number) => { + const totalSeconds = Math.max(0, Math.floor(durationMs / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +}; + +interface InProgressRecordingBarProps { + phase: RecorderPhase; + durationMs: number; + hasAudioTrack: boolean; + onStop: () => void | Promise; +} + +const DRAG_PADDING = 12; + +export const InProgressRecordingBar = ({ + phase, + durationMs, + hasAudioTrack, + onStop, +}: InProgressRecordingBarProps) => { + const [mounted, setMounted] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 24 }); + const [isDragging, setIsDragging] = useState(false); + const dragOffsetRef = useRef({ x: 0, y: 0 }); + const containerRef = useRef(null); + const initializedPositionRef = useRef(false); + + useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + useEffect(() => { + if (!mounted || initializedPositionRef.current) return; + if (typeof window === "undefined") return; + + const raf = window.requestAnimationFrame(() => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const maxX = window.innerWidth - rect.width - DRAG_PADDING; + initializedPositionRef.current = true; + setPosition({ + x: clamp((window.innerWidth - rect.width) / 2, DRAG_PADDING, maxX), + y: DRAG_PADDING * 2, + }); + }); + + return () => { + if (typeof window !== "undefined") { + window.cancelAnimationFrame(raf); + } + }; + }, [mounted]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const handleResize = () => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + setPosition((prev) => { + const maxX = window.innerWidth - rect.width - DRAG_PADDING; + const maxY = window.innerHeight - rect.height - DRAG_PADDING; + return { + x: clamp(prev.x, DRAG_PADDING, maxX), + y: clamp(prev.y, DRAG_PADDING, maxY), + }; + }); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const handlePointerDown = useCallback( + (event: ReactMouseEvent) => { + if ((event.target as HTMLElement)?.closest("[data-no-drag]") || + (event.button !== 0)) { + return; + } + + event.preventDefault(); + setIsDragging(true); + dragOffsetRef.current = { + x: event.clientX - position.x, + y: event.clientY - position.y, + }; + }, + [position], + ); + + useEffect(() => { + if (!isDragging || typeof window === "undefined") { + return undefined; + } + + const handleMouseMove = (event: MouseEvent) => { + const rect = containerRef.current?.getBoundingClientRect(); + const width = rect?.width ?? 360; + const height = rect?.height ?? 64; + const maxX = window.innerWidth - width - DRAG_PADDING; + const maxY = window.innerHeight - height - DRAG_PADDING; + + setPosition({ + x: clamp(event.clientX - dragOffsetRef.current.x, DRAG_PADDING, maxX), + y: clamp(event.clientY - dragOffsetRef.current.y, DRAG_PADDING, maxY), + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging]); + + if (!mounted || typeof document === "undefined") { + return null; + } + + const statusText = + phase === "recording" + ? formatDuration(durationMs) + : phaseMessages[phase] ?? "Processing"; + + const handleStop = () => { + onStop(); + }; + + const handlePause = () => { + console.log("Pause recording clicked (not implemented yet)"); + }; + + const handleRestart = () => { + console.log("Restart recording clicked (not implemented yet)"); + }; + + return createPortal( +
+
+
+ + +
+
+ {hasAudioTrack ? ( + <> + +
+
+
+ + ) : ( + + )} +
+ + + + + + + +
+
+
+ +
+
+
, + document.body, + ); +}; + +const ActionButton = ({ className, ...props }: ComponentProps<"button">) => ( + +
{import.meta.env.DEV && ( diff --git a/apps/desktop/src/components/Cropper.tsx b/apps/desktop/src/components/Cropper.tsx index 88379b2213..6509ce71cf 100644 --- a/apps/desktop/src/components/Cropper.tsx +++ b/apps/desktop/src/components/Cropper.tsx @@ -1,771 +1,1520 @@ import { createEventListenerMap } from "@solid-primitives/event-listener"; -import { makePersisted } from "@solid-primitives/storage"; -import { type CheckMenuItemOptions, Menu } from "@tauri-apps/api/menu"; +import { createResizeObserver } from "@solid-primitives/resize-observer"; +import type { + CheckMenuItemOptions, + PredefinedMenuItemOptions, +} from "@tauri-apps/api/menu"; import { type as ostype } from "@tauri-apps/plugin-os"; import { - batch, + type Accessor, + children, createEffect, createMemo, - createResource, createRoot, createSignal, For, on, - onCleanup, onMount, type ParentProps, Show, } from "solid-js"; import { createStore } from "solid-js/store"; import { Transition } from "solid-transition-group"; -import { generalSettingsStore } from "~/store"; -import Box from "~/utils/box"; -import { type Crop, commands, type XY } from "~/utils/tauri"; -import CropAreaRenderer from "./CropAreaRenderer"; +import { createKeyDownSignal } from "~/utils/events"; + +import { commands } from "~/utils/tauri"; +export interface CropBounds { + x: number; + y: number; + width: number; + height: number; +} +export const CROP_ZERO: CropBounds = { x: 0, y: 0, width: 0, height: 0 }; type Direction = "n" | "e" | "s" | "w" | "nw" | "ne" | "se" | "sw"; +type BoundsConstraints = { + top: boolean; + right: boolean; + bottom: boolean; + left: boolean; +}; +type Vec2 = { x: number; y: number }; + type HandleSide = { x: "l" | "r" | "c"; y: "t" | "b" | "c"; direction: Direction; - cursor: "ew" | "ns" | "nesw" | "nwse"; + cursor: string; + movable: BoundsConstraints; + origin: Vec2; + isCorner: boolean; }; -const HANDLES: HandleSide[] = [ - { x: "l", y: "t", direction: "nw", cursor: "nwse" }, - { x: "r", y: "t", direction: "ne", cursor: "nesw" }, - { x: "l", y: "b", direction: "sw", cursor: "nesw" }, - { x: "r", y: "b", direction: "se", cursor: "nwse" }, - { x: "c", y: "t", direction: "n", cursor: "ns" }, - { x: "c", y: "b", direction: "s", cursor: "ns" }, - { x: "l", y: "c", direction: "w", cursor: "ew" }, - { x: "r", y: "c", direction: "e", cursor: "ew" }, -]; - -type Ratio = [number, number]; -const COMMON_RATIOS: Ratio[] = [ +const HANDLES: readonly HandleSide[] = [ + { x: "l", y: "t", direction: "nw", cursor: "nwse-resize" }, + { x: "r", y: "t", direction: "ne", cursor: "nesw-resize" }, + { x: "l", y: "b", direction: "sw", cursor: "nesw-resize" }, + { x: "r", y: "b", direction: "se", cursor: "nwse-resize" }, + { x: "c", y: "t", direction: "n", cursor: "ns-resize" }, + { x: "c", y: "b", direction: "s", cursor: "ns-resize" }, + { x: "l", y: "c", direction: "w", cursor: "ew-resize" }, + { x: "r", y: "c", direction: "e", cursor: "ew-resize" }, +].map( + (handle) => + ({ + ...handle, + movable: { + top: handle.y === "t", + bottom: handle.y === "b", + left: handle.x === "l", + right: handle.x === "r", + }, + origin: { + x: handle.x === "l" ? 1 : handle.x === "r" ? 0 : 0.5, + y: handle.y === "t" ? 1 : handle.y === "b" ? 0 : 0.5, + }, + isCorner: handle.x !== "c" && handle.y !== "c", + }) as HandleSide, +); +export type Ratio = [number, number]; +export const COMMON_RATIOS: readonly Ratio[] = [ [1, 1], - [4, 3], + [2, 1], [3, 2], + [4, 3], + [9, 16], [16, 9], - [2, 1], + [16, 10], [21, 9], ]; +const ORIGIN_CENTER: Vec2 = { x: 0.5, y: 0.5 }; -const KEY_MAPPINGS = new Map([ - ["ArrowRight", "e"], - ["ArrowDown", "s"], - ["ArrowLeft", "w"], - ["ArrowUp", "n"], -]); +const ratioToValue = (r: Ratio) => r[0] / r[1]; +const clamp = (n: number, min = 0, max = 1) => Math.max(min, Math.min(max, n)); +const easeInOutCubic = (t: number) => + t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; -const ORIGIN_CENTER: XY = { x: 0.5, y: 0.5 }; +const shouldTriggerHaptic = ostype() === "macos"; +function triggerHaptic() { + if (shouldTriggerHaptic) commands.performHapticFeedback("alignment", null); +} -function clamp(n: number, min = 0, max = 1) { - return Math.max(min, Math.min(max, n)); +function findClosestRatio( + width: number, + height: number, + threshold = 0.01, +): Ratio | null { + const currentRatio = width / height; + for (const ratio of COMMON_RATIOS) { + if (Math.abs(currentRatio - ratio[0] / ratio[1]) < threshold) + return [ratio[0], ratio[1]]; + if (Math.abs(currentRatio - ratio[1] / ratio[0]) < threshold) + return [ratio[1], ratio[0]]; + } + return null; } -function distanceOf(firstPoint: Touch, secondPoint: Touch): number { - const dx = firstPoint.clientX - secondPoint.clientX; - const dy = firstPoint.clientY - secondPoint.clientY; - return Math.sqrt(dx * dx + dy * dy); +// ----------------------------- +// Bounds helpers +// ----------------------------- +function moveBounds( + bounds: CropBounds, + x: number | null, + y: number | null, +): CropBounds { + return { + ...bounds, + x: x !== null ? Math.round(x) : bounds.x, + y: y !== null ? Math.round(y) : bounds.y, + }; } -export function cropToFloor(value: Crop): Crop { +function resizeBounds( + bounds: CropBounds, + newWidth: number, + newHeight: number, + origin: Vec2, +): CropBounds { + const fromX = bounds.x + bounds.width * origin.x; + const fromY = bounds.y + bounds.height * origin.y; return { - size: { - x: Math.floor(value.size.x), - y: Math.floor(value.size.y), - }, - position: { - x: Math.floor(value.position.x), - y: Math.floor(value.position.y), - }, + x: Math.round(fromX - newWidth * origin.x), + y: Math.round(fromY - newHeight * origin.y), + width: Math.round(newWidth), + height: Math.round(newHeight), }; } -export default function Cropper( +function scaleBounds(bounds: CropBounds, factor: number, origin: Vec2) { + return resizeBounds( + bounds, + bounds.width * factor, + bounds.height * factor, + origin, + ); +} + +function constrainBoundsToRatio( + bounds: CropBounds, + ratio: number, + origin: Vec2, +) { + const currentRatio = bounds.width / bounds.height; + if (Math.abs(currentRatio - ratio) < 0.001) return bounds; + return resizeBounds(bounds, bounds.width, bounds.width / ratio, origin); +} + +function constrainBoundsToSize( + bounds: CropBounds, + max: Vec2 | null, + min: Vec2 | null, + origin: Vec2, + ratio: number | null = null, +) { + let next = { ...bounds }; + let maxW = max?.x ?? null; + let maxH = max?.y ?? null; + let minW = min?.x ?? null; + let minH = min?.y ?? null; + + if (ratio) { + // Correctly calculate effective min/max sizes when a ratio is present + if (minW && minH) { + const effectiveMinW = Math.max(minW, minH * ratio); + minW = effectiveMinW; + minH = effectiveMinW / ratio; + } + if (maxW && maxH) { + const effectiveMaxW = Math.min(maxW, maxH * ratio); + maxW = effectiveMaxW; + maxH = effectiveMaxW / ratio; + } + } + + if (maxW && next.width > maxW) + next = resizeBounds(next, maxW, ratio ? maxW / ratio : next.height, origin); + if (maxH && next.height > maxH) + next = resizeBounds(next, ratio ? maxH * ratio : next.width, maxH, origin); + if (minW && next.width < minW) + next = resizeBounds(next, minW, ratio ? minW / ratio : next.height, origin); + if (minH && next.height < minH) + next = resizeBounds(next, ratio ? minH * ratio : next.width, minH, origin); + + return next; +} + +function slideBoundsIntoContainer( + bounds: CropBounds, + containerWidth: number, + containerHeight: number, +): CropBounds { + let { x, y, width, height } = bounds; + + if (x < 0) x = 0; + if (y < 0) y = 0; + if (x + width > containerWidth) x = containerWidth - width; + if (y + height > containerHeight) y = containerHeight - height; + + return { ...bounds, x, y }; +} + +export type CropperRef = { + fill: () => void; + reset: () => void; + setCropProperty: (field: keyof CropBounds, value: number) => void; + setCrop: ( + value: CropBounds | ((b: CropBounds) => CropBounds), + origin?: Vec2, + ) => void; + bounds: Accessor; + animateTo: (real: CropBounds, durationMs?: number) => void; +}; + +export function Cropper( props: ParentProps<{ + onCropChange?: (bounds: CropBounds) => void; + onInteraction?: (interacting: boolean) => void; + onContextMenu?: (event: PointerEvent) => void; + ref?: CropperRef | ((ref: CropperRef) => void); class?: string; - onCropChange: (value: Crop) => void; - value: Crop; - mappedSize?: XY; - minSize?: XY; - initialSize?: XY; - aspectRatio?: number; - showGuideLines?: boolean; + minSize?: Vec2; + maxSize?: Vec2; + targetSize?: Vec2; + initialCrop?: CropBounds | (() => CropBounds | undefined); + aspectRatio?: Ratio; + showBounds?: boolean; + snapToRatioEnabled?: boolean; + useBackdropFilter?: boolean; + allowLightMode?: boolean; }>, ) { - const position = () => props.value.position; - const size = () => props.value.size; + let containerRef: HTMLDivElement | undefined; + let regionRef: HTMLDivElement | undefined; + let occTopRef: HTMLDivElement | undefined; + let occBottomRef: HTMLDivElement | undefined; + let occLeftRef: HTMLDivElement | undefined; + let occRightRef: HTMLDivElement | undefined; + + const resolvedChildren = children(() => props.children); + + // raw bounds are in "logical" coordinates (not scaled to targetSize) + const [rawBounds, setRawBounds] = createSignal(CROP_ZERO); + const [displayRawBounds, setDisplayRawBounds] = + createSignal(CROP_ZERO); + + const [isAnimating, setIsAnimating] = createSignal(false); + let animationFrameId: number | null = null; + const [isReady, setIsReady] = createSignal(false); + + function stopAnimation() { + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); + animationFrameId = null; + setIsAnimating(false); + setDisplayRawBounds(rawBounds()); + } - const [containerSize, setContainerSize] = createSignal({ x: 0, y: 0 }); - const mappedSize = createMemo(() => props.mappedSize || containerSize()); - const minSize = createMemo(() => { - const mapped = mappedSize(); - return { - x: Math.min(100, mapped.x * 0.1), - y: Math.min(100, mapped.y * 0.1), + const boundsTooSmall = createMemo( + () => displayRawBounds().width <= 30 || displayRawBounds().height <= 30, + ); + + const [mouseState, setMouseState] = createStore< + ( + | { drag: null | "region" | "overlay" } + | { drag: "handle"; cursor: string } + ) & { hoveringHandle: HandleSide | null } + >({ drag: null, hoveringHandle: null }); + + const resizing = () => + mouseState.drag === "handle" || mouseState.drag === "overlay"; + const cursorStyle = () => { + if (mouseState.drag === "region" || mouseState.drag === "overlay") + return "grabbing"; + if (mouseState.drag === "handle") return mouseState.cursor; + }; + + createEffect(() => props.onInteraction?.(mouseState.drag !== null)); + + const [aspectState, setAspectState] = createStore({ + snapped: null as Ratio | null, + value: null as number | null, + }); + + createEffect(() => { + const min = props.minSize; + const max = props.maxSize; + + if (min && max) { + if (min.x > max.x) + throw new Error( + `Cropper constraint error: minSize.x (${min.x}px) exceeds maxSize.x (${max.x}px). Please adjust the size constraints.`, + ); + if (min.y > max.y) + throw new Error( + `Cropper constraint error: minSize.y (${min.y}px) exceeds maxSize.y (${max.y}px). Please adjust the size constraints.`, + ); + } + }); + + createEffect( + on( + () => props.aspectRatio, + (v) => { + const nextRatio = v ? ratioToValue(v) : null; + setAspectState("value", nextRatio); + + if (!isReady() || !nextRatio) return; + let targetBounds = rawBounds(); + + targetBounds = constrainBoundsToRatio( + targetBounds, + nextRatio, + ORIGIN_CENTER, + ); + setRawBoundsAndAnimate(targetBounds); + }, + ), + ); + + const [containerSize, setContainerSize] = createSignal({ x: 1, y: 1 }); + const targetSize = createMemo(() => props.targetSize || containerSize()); + + const logicalScale = createMemo(() => { + if (props.targetSize) { + const target = props.targetSize; + const container = containerSize(); + return { x: target.x / container.x, y: target.y / container.y }; + } + return { x: 1, y: 1 }; + }); + + const realBounds = createMemo(() => { + const { x, y, width, height } = rawBounds(); + const scale = logicalScale(); + const target = targetSize(); + const bounds = { + x: Math.round(x * scale.x), + y: Math.round(y * scale.y), + width: Math.round(width * scale.x), + height: Math.round(height * scale.y), }; + + if (bounds.width > target.x) bounds.width = target.x; + if (bounds.height > target.y) bounds.height = target.y; + if (bounds.x < 0) bounds.x = 0; + if (bounds.y < 0) bounds.y = 0; + if (bounds.x + bounds.width > target.x) bounds.x = target.x - bounds.width; + if (bounds.y + bounds.height > target.y) + bounds.y = target.y - bounds.height; + + props.onCropChange?.(bounds); + return bounds; }); - const containerToMappedSizeScale = createMemo(() => { - const container = containerSize(); - const mapped = mappedSize(); + function calculateLabelTransform(handle: HandleSide) { + const bounds = rawBounds(); + if (!containerRef) return { x: 0, y: 0 }; + const containerRect = containerRef.getBoundingClientRect(); + const labelWidth = 80; + const labelHeight = 25; + const margin = 25; + + const handleScreenX = + containerRect.left + + bounds.x + + bounds.width * (handle.x === "l" ? 0 : handle.x === "r" ? 1 : 0.5); + const handleScreenY = + containerRect.top + + bounds.y + + bounds.height * (handle.y === "t" ? 0 : handle.y === "b" ? 1 : 0.5); + + let idealX = handleScreenX; + let idealY = handleScreenY; + + if (handle.x === "l") idealX -= labelWidth + margin; + else if (handle.x === "r") idealX += margin; + else idealX -= labelWidth / 2; + + if (handle.y === "t") idealY -= labelHeight + margin; + else if (handle.y === "b") idealY += margin; + else idealY -= labelHeight / 2; + + const finalX = clamp( + idealX, + margin, + window.innerWidth - labelWidth - margin, + ); + const finalY = clamp( + idealY, + margin, + window.innerHeight - labelHeight - margin, + ); + + return { x: finalX, y: finalY }; + } + + const labelTransform = createMemo(() => + resizing() && mouseState.hoveringHandle + ? calculateLabelTransform(mouseState.hoveringHandle) + : null, + ); + + function boundsToRaw(real: CropBounds) { + const scale = logicalScale(); return { - x: container.x / mapped.x, - y: container.y / mapped.y, + x: Math.max(0, real.x / scale.x), + y: Math.max(0, real.y / scale.y), + width: Math.max(0, real.width / scale.x), + height: Math.max(0, real.height / scale.y), }; - }); + } + + function animateToRawBounds(target: CropBounds, durationMs = 240) { + const start = displayRawBounds(); + if ( + target.x === start.x && + target.y === start.y && + target.width === start.width && + target.height === start.height + ) { + return; + } + + setIsAnimating(true); + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); + const startTime = performance.now(); + + const step = () => { + const now = performance.now(); + const t = Math.min(1, (now - startTime) / durationMs); + const e = easeInOutCubic(t); + setDisplayRawBounds({ + x: start.x + (target.x - start.x) * e, + y: start.y + (target.y - start.y) * e, + width: start.width + (target.width - start.width) * e, + height: start.height + (target.height - start.height) * e, + }); + if (t < 1) animationFrameId = requestAnimationFrame(step); + else { + animationFrameId = null; + setIsAnimating(false); + triggerHaptic(); + } + }; + + animationFrameId = requestAnimationFrame(step); + } + + function setRawBoundsAndAnimate( + bounds: CropBounds, + origin?: Vec2, + durationMs = 240, + ) { + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); + setIsAnimating(true); + setRawBoundsConstraining(bounds, origin); + animateToRawBounds(rawBounds(), durationMs); + } - const displayScaledCrop = createMemo(() => { - const mapped = mappedSize(); + function computeInitialBounds(): CropBounds { + const target = targetSize(); + const initialCrop = + typeof props.initialCrop === "function" + ? props.initialCrop() + : props.initialCrop; + + const startBoundsReal = initialCrop ?? { + x: 0, + y: 0, + width: Math.round(target.x / 2), + height: Math.round(target.y / 2), + }; + + let bounds = boundsToRaw(startBoundsReal); + const ratioValue = aspectState.value; + if (ratioValue) + bounds = constrainBoundsToRatio(bounds, ratioValue, ORIGIN_CENTER); const container = containerSize(); + + if (bounds.width > container.x) + bounds = scaleBounds(bounds, container.x / bounds.width, ORIGIN_CENTER); + if (bounds.height > container.y) + bounds = scaleBounds(bounds, container.y / bounds.height, ORIGIN_CENTER); + + bounds = slideBoundsIntoContainer(bounds, container.x, container.y); + + if (!initialCrop) + bounds = moveBounds( + bounds, + container.x / 2 - bounds.width / 2, + container.y / 2 - bounds.height / 2, + ); + return bounds; + } + + function rawSizeConstraint() { + const scale = logicalScale(); return { - x: (position().x / mapped.x) * container.x, - y: (position().y / mapped.y) * container.y, - width: (size().x / mapped.x) * container.x, - height: (size().y / mapped.y) * container.y, + min: props.minSize + ? { x: props.minSize.x / scale.x, y: props.minSize.y / scale.y } + : null, + max: props.maxSize + ? { x: props.maxSize.x / scale.x, y: props.maxSize.y / scale.y } + : null, }; - }); + } + + function setRawBoundsConstraining( + bounds: CropBounds, + origin = ORIGIN_CENTER, + ) { + const ratioValue = aspectState.value; + const container = containerSize(); + const { min, max } = rawSizeConstraint(); + let newBounds = { ...bounds }; + + newBounds = constrainBoundsToSize(newBounds, max, min, origin, ratioValue); + + if (ratioValue) + newBounds = constrainBoundsToRatio(newBounds, ratioValue, origin); + + if (newBounds.width > container.x) + newBounds = scaleBounds(newBounds, container.x / newBounds.width, origin); + if (newBounds.height > container.y) + newBounds = scaleBounds( + newBounds, + container.y / newBounds.height, + origin, + ); + + newBounds = slideBoundsIntoContainer(newBounds, container.x, container.y); + setRawBounds(newBounds); + if (!isAnimating()) setDisplayRawBounds(newBounds); + } + + function fill() { + const container = containerSize(); + const targetRaw = { + x: 0, + y: 0, + width: container.x, + height: container.y, + }; + setRawBoundsAndAnimate(targetRaw); + setAspectState("snapped", null); + } - let containerRef: HTMLDivElement | undefined; onMount(() => { if (!containerRef) return; + let initialized = false; - const updateContainerSize = () => { - setContainerSize({ - x: containerRef!.clientWidth, - y: containerRef!.clientHeight, - }); - }; + const updateContainerSize = (width: number, height: number) => { + const prevScale = logicalScale(); + const currentRaw = rawBounds(); + const preservedReal = { + x: Math.round(currentRaw.x * prevScale.x), + y: Math.round(currentRaw.y * prevScale.y), + width: Math.round(currentRaw.width * prevScale.x), + height: Math.round(currentRaw.height * prevScale.y), + }; - updateContainerSize(); - const resizeObserver = new ResizeObserver(updateContainerSize); - resizeObserver.observe(containerRef); - onCleanup(() => resizeObserver.disconnect()); + setContainerSize({ x: width, y: height }); - const mapped = mappedSize(); - const initial = props.initialSize || { - x: mapped.x / 2, - y: mapped.y / 2, - }; + setRawBoundsConstraining(boundsToRaw(preservedReal)); - const width = clamp(initial.x, minSize().x, mapped.x); - const height = clamp(initial.y, minSize().y, mapped.y); + if (!initialized && width > 1 && height > 1) { + initialized = true; + init(); + } + }; - const box = Box.from( - { x: (mapped.x - width) / 2, y: (mapped.y - height) / 2 }, - { x: width, y: height }, + createResizeObserver(containerRef, (e) => + updateContainerSize(e.width, e.height), ); - box.constrainAll(box, containerSize(), ORIGIN_CENTER, props.aspectRatio); + updateContainerSize(containerRef.clientWidth, containerRef.clientHeight); - setCrop({ - size: { x: width, y: height }, - position: { - x: (mapped.x - width) / 2, - y: (mapped.y - height) / 2, - }, - }); - }); + setDisplayRawBounds(rawBounds()); - createEffect( - on( - () => props.aspectRatio, - () => { - if (!props.aspectRatio) return; - const box = Box.from(position(), size()); - box.constrainToRatio(props.aspectRatio, ORIGIN_CENTER); - box.constrainToBoundary(mappedSize().x, mappedSize().y, ORIGIN_CENTER); - setCrop(box.toBounds()); - }, - ), - ); + function init() { + const bounds = computeInitialBounds(); + setRawBoundsConstraining(bounds); + setDisplayRawBounds(bounds); + setIsReady(true); + } - const [snapToRatioEnabled, setSnapToRatioEnabled] = makePersisted( - createSignal(true), - { name: "cropSnapsToRatio" }, - ); - const [snappedRatio, setSnappedRatio] = createSignal(null); - const [dragging, setDragging] = createSignal(false); - const [gestureState, setGestureState] = createStore<{ - isTrackpadGesture: boolean; - lastTouchCenter: XY | null; - initialPinchDistance: number; - initialSize: { width: number; height: number }; - }>({ - isTrackpadGesture: false, - lastTouchCenter: null, - initialPinchDistance: 0, - initialSize: { width: 0, height: 0 }, + if (props.ref) { + const cropperRef: CropperRef = { + reset: () => { + const bounds = computeInitialBounds(); + setRawBoundsAndAnimate(bounds); + setAspectState("snapped", null); + }, + fill, + setCropProperty: (field, value) => { + setAspectState("snapped", null); + setRawBoundsConstraining( + boundsToRaw({ ...realBounds(), [field]: value }), + { x: 0, y: 0 }, + ); + }, + setCrop: (value, origin) => + setRawBoundsConstraining( + boundsToRaw( + typeof value === "function" ? value(rawBounds()) : value, + ), + origin, + ), + get bounds() { + return realBounds; + }, + animateTo: (real, durationMs) => + setRawBoundsAndAnimate(boundsToRaw(real), undefined, durationMs), + }; + + if (typeof props.ref === "function") props.ref(cropperRef); + else props.ref = cropperRef; + } }); - function handleDragStart(event: MouseEvent) { - if (gestureState.isTrackpadGesture) return; // Don't start drag if we're in a trackpad gesture - event.stopPropagation(); - setDragging(true); - let lastValidPos = { x: event.clientX, y: event.clientY }; - const box = Box.from(position(), size()); - const scaleFactors = containerToMappedSizeScale(); + function onRegionPointerDown(e: PointerEvent) { + if (!containerRef || e.button !== 0) return; + + stopAnimation(); + e.stopPropagation(); + setMouseState({ drag: "region" }); + let currentBounds = rawBounds(); + const containerRect = containerRef.getBoundingClientRect(); + const startOffset = { + x: e.clientX - containerRect.left - currentBounds.x, + y: e.clientY - containerRect.top - currentBounds.y, + }; - createRoot((dispose) => { - const mapped = mappedSize(); + createRoot((dispose) => createEventListenerMap(window, { - mouseup: () => { - setDragging(false); + pointerup: () => { + setMouseState({ drag: null }); dispose(); }, - mousemove: (e) => { - requestAnimationFrame(() => { - const dx = (e.clientX - lastValidPos.x) / scaleFactors.x; - const dy = (e.clientY - lastValidPos.y) / scaleFactors.y; - - box.move( - clamp(box.x + dx, 0, mapped.x - box.width), - clamp(box.y + dy, 0, mapped.y - box.height), - ); - - const newBox = box; - if (newBox.x !== position().x || newBox.y !== position().y) { - lastValidPos = { x: e.clientX, y: e.clientY }; - setCrop(newBox.toBounds()); - } - }); + pointermove: (e) => { + let newX = e.clientX - containerRect.left - startOffset.x; + let newY = e.clientY - containerRect.top - startOffset.y; + + newX = clamp(newX, 0, containerRect.width - currentBounds.width); + newY = clamp(newY, 0, containerRect.height - currentBounds.height); + + currentBounds = moveBounds(currentBounds, newX, newY); + setRawBounds(currentBounds); + + if (!isAnimating()) setDisplayRawBounds(currentBounds); }, - }); - }); + }), + ); } - function handleWheel(event: WheelEvent) { - event.preventDefault(); - const box = Box.from(position(), size()); - const mapped = mappedSize(); + // Helper: update handle movable sides when switching between anchor <-> center-origin mode + function updateHandleForModeSwitch( + handle: HandleSide, + currentBounds: CropBounds, + pointX: number, + pointY: number, + ) { + const center = { + x: currentBounds.x + currentBounds.width / 2, + y: currentBounds.y + currentBounds.height / 2, + }; + const newMovable = { ...handle.movable }; + if (handle.movable.left || handle.movable.right) { + newMovable.left = pointX < center.x; + newMovable.right = pointX >= center.x; + } + if (handle.movable.top || handle.movable.bottom) { + newMovable.top = pointY < center.y; + newMovable.bottom = pointY >= center.y; + } + return { ...handle, movable: newMovable }; + } - if (event.ctrlKey) { - setGestureState("isTrackpadGesture", true); + type ResizeSessionState = { + startBounds: CropBounds; + isAltMode: boolean; + activeHandle: HandleSide; + originalHandle: HandleSide; + containerRect: DOMRect; + }; - const velocity = Math.max(0.001, Math.abs(event.deltaY) * 0.001); - const scale = 1 - event.deltaY * velocity; + function handleResizePointerMove( + e: PointerEvent, + context: ResizeSessionState, + ) { + const pointX = e.clientX - context.containerRect.left; + const pointY = e.clientY - context.containerRect.top; + + if (e.altKey !== context.isAltMode) { + context.isAltMode = e.altKey; + context.startBounds = rawBounds(); + if (context.isAltMode) { + context.activeHandle = context.originalHandle; + } else { + context.activeHandle = updateHandleForModeSwitch( + context.originalHandle, + context.startBounds, + pointX, + pointY, + ); + } + } - box.resize( - clamp(box.width * scale, minSize().x, mapped.x), - clamp(box.height * scale, minSize().y, mapped.y), - ORIGIN_CENTER, - ); - box.constrainAll(box, mapped, ORIGIN_CENTER, props.aspectRatio); - setTimeout(() => setGestureState("isTrackpadGesture", false), 100); - setSnappedRatio(null); + const { min, max } = rawSizeConstraint(); + const shiftKey = e.shiftKey; + const ratioValue = aspectState.value; + + const options: ResizeOptions = { + container: containerSize(), + min, + max, + isAltMode: context.isAltMode, + shiftKey, + ratioValue, + snapToRatioEnabled: !!props.snapToRatioEnabled && !boundsTooSmall(), + }; + + let nextBounds: CropBounds; + + if (ratioValue !== null) { + nextBounds = + computeAspectRatioResize( + pointX, + pointY, + context.startBounds, + context.activeHandle, + options, + ) ?? rawBounds(); } else { - const velocity = Math.max(1, Math.abs(event.deltaY) * 0.01); - const scaleFactors = containerToMappedSizeScale(); - const dx = (-event.deltaX * velocity) / scaleFactors.x; - const dy = (-event.deltaY * velocity) / scaleFactors.y; - - box.move( - clamp(box.x + dx, 0, mapped.x - box.width), - clamp(box.y + dy, 0, mapped.y - box.height), + const { bounds, snappedRatio } = computeFreeResize( + pointX, + pointY, + context.startBounds, + context.activeHandle, + options, ); + nextBounds = bounds; + if (snappedRatio && !aspectState.snapped) { + triggerHaptic(); + } + setAspectState("snapped", snappedRatio); } - setCrop(box.toBounds()); - } + const finalBounds = slideBoundsIntoContainer( + nextBounds, + containerSize().x, + containerSize().y, + ); - function handleTouchStart(event: TouchEvent) { - if (event.touches.length === 2) { - // Initialize pinch zoom - const distance = distanceOf(event.touches[0], event.touches[1]); - - // Initialize touch center - const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2; - const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2; - - batch(() => { - setGestureState("initialPinchDistance", distance); - setGestureState("initialSize", { - width: size().x, - height: size().y, - }); - setGestureState("lastTouchCenter", { x: centerX, y: centerY }); - }); - } else if (event.touches.length === 1) { - // Handle single touch as drag - batch(() => { - setDragging(true); - setGestureState("lastTouchCenter", { - x: event.touches[0].clientX, - y: event.touches[0].clientY, - }); - }); - } + setRawBounds(finalBounds); + if (!isAnimating()) setDisplayRawBounds(finalBounds); } - function handleTouchMove(event: TouchEvent) { - if (event.touches.length === 2) { - // Handle pinch zoom - const currentDistance = distanceOf(event.touches[0], event.touches[1]); - const scale = currentDistance / gestureState.initialPinchDistance; - - const box = Box.from(position(), size()); - const mapped = mappedSize(); - - // Calculate new dimensions while maintaining aspect ratio - const currentRatio = size().x / size().y; - let newWidth = clamp( - gestureState.initialSize.width * scale, - minSize().x, - mapped.x, - ); - let newHeight = newWidth / currentRatio; - - // Adjust if height exceeds bounds - if (newHeight < minSize().y || newHeight > mapped.y) { - newHeight = clamp(newHeight, minSize().y, mapped.y); - newWidth = newHeight * currentRatio; - } + function onHandlePointerDown(handle: HandleSide, e: PointerEvent) { + if (!containerRef || e.button !== 0) return; + e.stopPropagation(); - // Resize from center - box.resize(newWidth, newHeight, ORIGIN_CENTER); + stopAnimation(); + setMouseState({ drag: "handle", cursor: handle.cursor }); - // Handle two-finger pan - const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2; - const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2; + const context: ResizeSessionState = { + containerRect: containerRef.getBoundingClientRect(), + startBounds: rawBounds(), + isAltMode: e.altKey, + activeHandle: { ...handle }, + originalHandle: handle, + }; - if (gestureState.lastTouchCenter) { - const scaleFactors = containerToMappedSizeScale(); - const dx = (centerX - gestureState.lastTouchCenter.x) / scaleFactors.x; - const dy = (centerY - gestureState.lastTouchCenter.y) / scaleFactors.y; + createRoot((dispose) => + createEventListenerMap(window, { + pointerup: () => { + setMouseState({ drag: null }); + // Note: may need to be added back + // setAspectState("snapped", null); + dispose(); + }, + pointermove: (e) => handleResizePointerMove(e, context), + }), + ); + } - box.move( - clamp(box.x + dx, 0, mapped.x - box.width), - clamp(box.y + dy, 0, mapped.y - box.height), - ); - } + function onHandleDoubleClick(handle: HandleSide, e: MouseEvent) { + e.stopPropagation(); + const currentBounds = rawBounds(); + const container = containerSize(); - setGestureState("lastTouchCenter", { x: centerX, y: centerY }); - setCrop(box.toBounds()); - } else if (event.touches.length === 1 && dragging()) { - // Handle single touch drag - const box = Box.from(position(), size()); - const scaleFactors = containerToMappedSizeScale(); - const mapped = mappedSize(); - - if (gestureState.lastTouchCenter) { - const dx = - (event.touches[0].clientX - gestureState.lastTouchCenter.x) / - scaleFactors.x; - const dy = - (event.touches[0].clientY - gestureState.lastTouchCenter.y) / - scaleFactors.y; - - box.move( - clamp(box.x + dx, 0, mapped.x - box.width), - clamp(box.y + dy, 0, mapped.y - box.height), - ); - } + const newBounds = { ...currentBounds }; - setGestureState("lastTouchCenter", { - x: event.touches[0].clientX, - y: event.touches[0].clientY, - }); - setCrop(box.toBounds()); + if (handle.movable.top) { + newBounds.height = currentBounds.y + currentBounds.height; + newBounds.y = 0; } - } - - function handleTouchEnd(event: TouchEvent) { - if (event.touches.length === 0) { - setDragging(false); - setGestureState("lastTouchCenter", null); - } else if (event.touches.length === 1) { - setGestureState("lastTouchCenter", { - x: event.touches[0].clientX, - y: event.touches[0].clientY, - }); + if (handle.movable.bottom) { + newBounds.height = container.y - currentBounds.y; + } + if (handle.movable.left) { + newBounds.width = currentBounds.x + currentBounds.width; + newBounds.x = 0; + } + if (handle.movable.right) { + newBounds.width = container.x - currentBounds.x; } - } - function handleResizeStartTouch(event: TouchEvent, dir: Direction) { - if (event.touches.length !== 1) return; - event.stopPropagation(); - const touch = event.touches[0]; - handleResizeStart(touch.clientX, touch.clientY, dir); + setRawBoundsAndAnimate(newBounds, handle.origin); } - function findClosestRatio( - width: number, - height: number, - threshold = 0.01, - ): Ratio | null { - if (props.aspectRatio) return null; - const currentRatio = width / height; - for (const ratio of COMMON_RATIOS) { - if (Math.abs(currentRatio - ratio[0] / ratio[1]) < threshold) { - return [ratio[0], ratio[1]]; - } - if (Math.abs(currentRatio - ratio[1] / ratio[0]) < threshold) { - return [ratio[1], ratio[0]]; - } - } - return null; - } + function onOverlayPointerDown(e: PointerEvent) { + if (!containerRef || e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + const initialBounds = { ...rawBounds() }; + const SE_HANDLE_INDEX = 3; // use bottom-right as the temporary handle + const handle = HANDLES[SE_HANDLE_INDEX]; + + setMouseState({ drag: "overlay" }); - function handleResizeStart(clientX: number, clientY: number, dir: Direction) { - const origin: XY = { - x: dir.includes("w") ? 1 : 0, - y: dir.includes("n") ? 1 : 0, + const containerRect = containerRef.getBoundingClientRect(); + const startPoint = { + x: e.clientX - containerRect.left, + y: e.clientY - containerRect.top, }; - let lastValidPos = { x: clientX, y: clientY }; - const box = Box.from(position(), size()); - const scaleFactors = containerToMappedSizeScale(); - const mapped = mappedSize(); + const startBounds: CropBounds = { + x: startPoint.x, + y: startPoint.y, + width: 1, + height: 1, + }; + + const context: ResizeSessionState = { + containerRect, + startBounds, + isAltMode: e.altKey, + activeHandle: { ...handle }, + originalHandle: handle, + }; createRoot((dispose) => { createEventListenerMap(window, { - mouseup: dispose, - touchend: dispose, - touchmove: (e) => - requestAnimationFrame(() => { - if (e.touches.length !== 1) return; - handleResizeMove(e.touches[0].clientX, e.touches[0].clientY); - }), - mousemove: (e) => - requestAnimationFrame(() => - handleResizeMove(e.clientX, e.clientY, e.altKey), - ), + pointerup: () => { + setMouseState({ drag: null }); + const bounds = rawBounds(); + if (bounds.width < 5 || bounds.height < 5) { + setRawBounds(initialBounds); + if (!isAnimating()) setDisplayRawBounds(initialBounds); + } + dispose(); + }, + pointermove: (e) => handleResizePointerMove(e, context), }); }); + } - const [hapticsEnabled, hapticsEnabledOptions] = createResource( - async () => - (await generalSettingsStore.get())?.hapticsEnabled && - ostype() === "macos", - ); - generalSettingsStore.listen(() => hapticsEnabledOptions.refetch()); + const KEY_MAPPINGS = new Map([ + ["ArrowRight", "e"], + ["ArrowDown", "s"], + ["ArrowLeft", "w"], + ["ArrowUp", "n"], + ]); + + const [keyboardState, setKeyboardState] = createStore({ + pressedKeys: new Set(), + shift: false, + alt: false, + meta: false, // Cmd or Ctrl + }); - function handleResizeMove( - moveX: number, - moveY: number, - centerOrigin = false, - ) { - const dx = (moveX - lastValidPos.x) / scaleFactors.x; - const dy = (moveY - lastValidPos.y) / scaleFactors.y; - - const scaleMultiplier = centerOrigin ? 2 : 1; - const currentBox = box.toBounds(); - - let newWidth = - dir.includes("e") || dir.includes("w") - ? clamp( - dir.includes("w") - ? currentBox.size.x - dx * scaleMultiplier - : currentBox.size.x + dx * scaleMultiplier, - minSize().x, - mapped.x, - ) - : currentBox.size.x; - - let newHeight = - dir.includes("n") || dir.includes("s") - ? clamp( - dir.includes("n") - ? currentBox.size.y - dy * scaleMultiplier - : currentBox.size.y + dy * scaleMultiplier, - minSize().y, - mapped.y, - ) - : currentBox.size.y; - - const closest = findClosestRatio(newWidth, newHeight); - if (dir.length === 2 && snapToRatioEnabled() && closest) { - const ratio = closest[0] / closest[1]; - if (dir.includes("n") || dir.includes("s")) { - newWidth = newHeight * ratio; - } else { - newHeight = newWidth / ratio; - } - if (!snappedRatio() && hapticsEnabled()) { - commands.performHapticFeedback("Alignment", "Now"); - } - setSnappedRatio(closest); - } else { - setSnappedRatio(null); - } + let keyboardFrameId: number | null = null; - const newOrigin = centerOrigin ? ORIGIN_CENTER : origin; - box.resize(newWidth, newHeight, newOrigin); + function keyboardActionLoop() { + const currentBounds = rawBounds(); + const { pressedKeys, shift, alt, meta } = keyboardState; - if (props.aspectRatio) { - box.constrainToRatio( - props.aspectRatio, - newOrigin, - dir.includes("n") || dir.includes("s") ? "width" : "height", - ); - } - box.constrainToBoundary(mapped.x, mapped.y, newOrigin); - - const newBox = box.toBounds(); - if ( - newBox.size.x !== size().x || - newBox.size.y !== size().y || - newBox.position.x !== position().x || - newBox.position.y !== position().y - ) { - lastValidPos = { x: moveX, y: moveY }; - props.onCropChange(newBox); - } + const delta = shift ? 10 : 2; + + if (meta) { + // Resize + const origin = alt ? ORIGIN_CENTER : { x: 0, y: 0 }; + let newWidth = currentBounds.width; + let newHeight = currentBounds.height; + + if (pressedKeys.has("ArrowLeft")) newWidth -= delta; + if (pressedKeys.has("ArrowRight")) newWidth += delta; + if (pressedKeys.has("ArrowUp")) newHeight -= delta; + if (pressedKeys.has("ArrowDown")) newHeight += delta; + + newWidth = Math.max(1, newWidth); + newHeight = Math.max(1, newHeight); + + const resized = resizeBounds(currentBounds, newWidth, newHeight, origin); + + setRawBoundsConstraining(resized, origin); + } else { + // Move + let dx = 0; + let dy = 0; + + if (pressedKeys.has("ArrowLeft")) dx -= delta; + if (pressedKeys.has("ArrowRight")) dx += delta; + if (pressedKeys.has("ArrowUp")) dy -= delta; + if (pressedKeys.has("ArrowDown")) dy += delta; + + const moved = moveBounds( + currentBounds, + currentBounds.x + dx, + currentBounds.y + dy, + ); + + setRawBoundsConstraining(moved); } - } - function setCrop(value: Crop) { - props.onCropChange(value); + keyboardFrameId = requestAnimationFrame(keyboardActionLoop); } - const pressedKeys = new Set([]); - let lastKeyHandleFrame: number | null = null; - function handleKeyDown(event: KeyboardEvent) { - if (dragging()) return; - const dir = KEY_MAPPINGS.get(event.key); - if (!dir) return; - event.preventDefault(); - pressedKeys.add(event.key); - - if (lastKeyHandleFrame) return; - lastKeyHandleFrame = requestAnimationFrame(() => { - const box = Box.from(position(), size()); - const mapped = mappedSize(); - const scaleFactors = containerToMappedSizeScale(); - - const moveDelta = event.shiftKey ? 20 : 5; - const origin = event.altKey ? ORIGIN_CENTER : { x: 0, y: 0 }; - - for (const key of pressedKeys) { - const dir = KEY_MAPPINGS.get(key); - if (!dir) continue; - - const isUpKey = dir === "n"; - const isLeftKey = dir === "w"; - const isDownKey = dir === "s"; - const isRightKey = dir === "e"; - - if (event.metaKey || event.ctrlKey) { - const scaleMultiplier = event.altKey ? 2 : 1; - const currentBox = box.toBounds(); - - let newWidth = currentBox.size.x; - let newHeight = currentBox.size.y; - - if (isLeftKey || isRightKey) { - newWidth = clamp( - isLeftKey - ? currentBox.size.x - moveDelta * scaleMultiplier - : currentBox.size.x + moveDelta * scaleMultiplier, - minSize().x, - mapped.x, - ); - } + function handleKeyDown(e: KeyboardEvent) { + if (!KEY_MAPPINGS.has(e.key) || mouseState.drag !== null) return; - if (isUpKey || isDownKey) { - newHeight = clamp( - isUpKey - ? currentBox.size.y - moveDelta * scaleMultiplier - : currentBox.size.y + moveDelta * scaleMultiplier, - minSize().y, - mapped.y, - ); - } + e.preventDefault(); + e.stopPropagation(); - box.resize(newWidth, newHeight, origin); - } else { - const dx = - (isRightKey ? moveDelta : isLeftKey ? -moveDelta : 0) / - scaleFactors.x; - const dy = - (isDownKey ? moveDelta : isUpKey ? -moveDelta : 0) / scaleFactors.y; - - box.move( - clamp(box.x + dx, 0, mapped.x - box.width), - clamp(box.y + dy, 0, mapped.y - box.height), - ); - } - } + setKeyboardState("pressedKeys", (p) => p.add(e.key)); + setKeyboardState({ + shift: e.shiftKey, + alt: e.altKey, + meta: e.metaKey || e.ctrlKey, + }); + + if (!keyboardFrameId) { + stopAnimation(); + keyboardActionLoop(); + } + } - if (props.aspectRatio) box.constrainToRatio(props.aspectRatio, origin); - box.constrainToBoundary(mapped.x, mapped.y, origin); - setCrop(box.toBounds()); + function handleKeyUp(e: KeyboardEvent) { + if ( + !KEY_MAPPINGS.has(e.key) && + !["Shift", "Alt", "Meta", "Control"].includes(e.key) + ) + return; - pressedKeys.clear(); - lastKeyHandleFrame = null; + e.preventDefault(); + + setKeyboardState("pressedKeys", (p) => { + p.delete(e.key); + return p; + }); + + setKeyboardState({ + shift: e.shiftKey, + alt: e.altKey, + meta: e.metaKey || e.ctrlKey, }); + + if (keyboardState.pressedKeys.size === 0) { + if (keyboardFrameId) { + cancelAnimationFrame(keyboardFrameId); + keyboardFrameId = null; + } + } } + // Only update during a frame animation. + // Note: Doing this any other way can very likely cause a huge memory usage or even leak until the resizing stops. + createEffect( + on(displayRawBounds, (b, _prevIn, prevFrameId) => { + if (prevFrameId) cancelAnimationFrame(prevFrameId); + return requestAnimationFrame(() => { + if (regionRef) { + regionRef.style.width = `${Math.round(b.width)}px`; + regionRef.style.height = `${Math.round(b.height)}px`; + regionRef.style.transform = `translate(${Math.round(b.x)}px,${Math.round(b.y)}px)`; + } + if (occLeftRef) { + occLeftRef.style.width = `${Math.max(0, Math.round(b.x))}px`; + } + if (occRightRef) { + occRightRef.style.left = `${Math.round(b.x + b.width)}px`; + } + if (occTopRef) { + occTopRef.style.left = `${Math.round(b.x)}px`; + occTopRef.style.width = `${Math.round(b.width)}px`; + occTopRef.style.height = `${Math.max(0, Math.round(b.y))}px`; + } + if (occBottomRef) { + occBottomRef.style.top = `${Math.round(b.y + b.height)}px`; + occBottomRef.style.left = `${Math.round(b.x)}px`; + occBottomRef.style.width = `${Math.round(b.width)}px`; + } + }); + }), + ); + + const altDown = createKeyDownSignal(window, "Alt"); + return (
{ - // e.preventDefault(); - // const menu = await Menu.new({ - // id: "crop-options", - // items: [ - // { - // id: "enableRatioSnap", - // text: "Snap to aspect ratios", - // checked: snapToRatioEnabled(), - // action: () => { - // setSnapToRatioEnabled((v) => !v); - // }, - // } satisfies CheckMenuItemOptions, - // ], - // }); - // menu.popup(); - // }} + onContextMenu={props.onContextMenu} + onDblClick={() => fill()} > - - {props.children} - + + {(transform) => ( +
+ {realBounds().width} x {realBounds().height} +
+ )} +
+ + + {resolvedChildren()} + + {/* Occluder */}