diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 46eeb102..d1a60846 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -21,6 +21,12 @@ COPY packages/auth/ packages/auth/ ENV NODE_ENV production ENV NEXT_TELEMETRY_DISABLED 1 +# Set build-time environment variables for validation +ENV DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" +ENV BETTER_AUTH_SECRET="build-time-secret" +ENV UPSTASH_REDIS_REST_URL="http://localhost:8079" +ENV UPSTASH_REDIS_REST_TOKEN="example_token" +ENV NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000" WORKDIR /app/apps/web RUN bun run build diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index 87547bd0..6b7a1605 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -1,9 +1,8 @@ import type { Config } from "drizzle-kit"; import * as dotenv from "dotenv"; -import { env } from "@/env"; // Load the right env file based on environment -if (env.NODE_ENV === "production") { +if (process.env.NODE_ENV === "production") { dotenv.config({ path: ".env.production" }); } else { dotenv.config({ path: ".env.local" }); @@ -13,8 +12,8 @@ export default { schema: "../../packages/db/src/schema.ts", dialect: "postgresql", dbCredentials: { - url: env.DATABASE_URL, + url: process.env.DATABASE_URL!, }, out: "./migrations", - strict: env.NODE_ENV === "production", + strict: process.env.NODE_ENV === "production", } satisfies Config; diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index ec79bd7f..f28cd191 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -119,7 +119,11 @@ const LoginPage = () => { className="w-full h-11" size="lg" > - {isEmailLoading ? : "Sign in"} + {isEmailLoading ? ( + + ) : ( + "Sign in" + )} @@ -137,6 +141,6 @@ const LoginPage = () => { ); -} +}; export default memo(LoginPage); diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx index e9705213..c3a0f899 100644 --- a/apps/web/src/app/(auth)/signup/page.tsx +++ b/apps/web/src/app/(auth)/signup/page.tsx @@ -157,6 +157,6 @@ const SignUpPage = () => { ); -} +}; export default memo(SignUpPage); diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index 393fb7e7..55510dcb 100644 --- a/apps/web/src/app/api/auth/[...all]/route.ts +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@opencut/auth/server"; +import { auth } from "@opencut/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { POST, GET } = toNextJsHandler(auth); \ No newline at end of file diff --git a/apps/web/src/app/api/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts index f62e24e9..e5c9fd94 100644 --- a/apps/web/src/app/api/waitlist/route.ts +++ b/apps/web/src/app/api/waitlist/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { db, eq } from "@opencut/db"; -import { waitlist } from "@opencut/db/schema"; +import { db, eq, waitlist } from "@opencut/db"; import { checkBotId } from "botid/server"; import { nanoid } from "nanoid"; import { waitlistRateLimit } from "@/lib/rate-limit"; diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index c4081c75..3b51e182 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -7,6 +7,7 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { HeaderBase } from "./header-base"; import { formatTimeCode } from "@/lib/time"; import { useProjectStore } from "@/stores/project-store"; +import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; export function EditorHeader() { const { getTotalDuration } = useTimelineStore(); @@ -43,6 +44,7 @@ export function EditorHeader() { const rightContent = ( + { initializeApp(); }, [initializeApp]); diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index c6e6a331..bfb663c6 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -55,6 +55,7 @@ import { getCumulativeHeightBefore, getTotalTracksHeight, TIMELINE_CONSTANTS, + snapTimeToFrame, } from "@/constants/timeline-constants"; import { Slider } from "../ui/slider"; import TimelineCanvasRuler from "./timeline-canvas/timeline-canvas-ruler"; @@ -64,6 +65,7 @@ export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their elements. // You can drag media here to add it to your project. // elements can be trimmed, deleted, and moved. + const { tracks, addTrack, @@ -232,7 +234,6 @@ export function Timeline() { // Use frame snapping for timeline clicking const projectFps = activeProject?.fps || 30; - const { snapTimeToFrame } = require("@/constants/timeline-constants"); const time = snapTimeToFrame(rawTime, projectFps); seek(time); @@ -254,71 +255,6 @@ export function Timeline() { setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline }, [tracks, setDuration, getTotalDuration]); - // Keyboard event for deleting selected elements - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't trigger when typing in input fields or textareas - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement - ) { - return; - } - - // Only trigger when timeline is focused or mouse is over timeline - if ( - !isInTimeline && - !timelineRef.current?.contains(document.activeElement) - ) { - return; - } - - if ( - (e.key === "Delete" || e.key === "Backspace") && - selectedElements.length > 0 - ) { - selectedElements.forEach(({ trackId, elementId }) => { - removeElementFromTrack(trackId, elementId); - }); - clearSelectedElements(); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - selectedElements, - removeElementFromTrack, - clearSelectedElements, - isInTimeline, - ]); - - // Keyboard event for undo (Cmd+Z) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { - e.preventDefault(); - undo(); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [undo]); - - // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { - e.preventDefault(); - redo(); - } else if ((e.metaKey || e.ctrlKey) && e.key === "y") { - e.preventDefault(); - redo(); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [redo]); - // Old marquee system removed - using new SelectionBox component instead const handleDragEnter = (e: React.DragEvent) => { @@ -376,7 +312,9 @@ export function Timeline() { useTimelineStore.getState().addTextToNewTrack(dragData); } else { // Handle media items - const mediaItem = mediaItems.find((item) => item.id === dragData.id); + const mediaItem = mediaItems.find( + (item: any) => item.id === dragData.id + ); if (!mediaItem) { toast.error("Media item not found"); return; diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx new file mode 100644 index 00000000..9f0d1e57 --- /dev/null +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; +import { Badge } from "./ui/badge"; +import { Keyboard } from "lucide-react"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; + +const KeyBadge = ({ keyName }: { keyName: string }) => { + // Replace common key names with symbols + const displayKey = keyName + .replace("Cmd", "⌘") + .replace("Shift", "⇧") + .replace("←", "◀") + .replace("→", "▶") + .replace("Space", "⎵"); + + return ( + + {displayKey} + + ); +}; + +const ShortcutItem = ({ shortcut }: { shortcut: any }) => ( + + + {shortcut.icon && ( + {shortcut.icon} + )} + {shortcut.description} + + + {shortcut.keys.map((key: string, index: number) => ( + + + {index < shortcut.keys.length - 1 && ( + + + )} + + ))} + + +); + +export const KeyboardShortcutsHelp = () => { + const [open, setOpen] = useState(false); + + // Get shortcuts from centralized hook (disabled so it doesn't add event listeners) + const { shortcuts } = useKeyboardShortcuts({ enabled: false }); + + const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + + return ( + + + + + Shortcuts + + + + + + + Keyboard Shortcuts + + + Speed up your video editing workflow with these keyboard shortcuts. + Most shortcuts work when the timeline is focused. + + + + + {categories.map((category) => ( + + + {category} + + + {shortcuts + .filter((shortcut) => shortcut.category === category) + .map((shortcut, index) => ( + + ))} + + + ))} + + + + Tips: + + + • Shortcuts work when the editor is focused (not typing in inputs) + + • J/K/L are industry-standard video editing shortcuts + • Use arrow keys for frame-perfect positioning + • Hold Shift with arrow keys for larger jumps + + + + + ); +}; diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index b1910dc5..6e6a45cb 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -84,7 +84,9 @@ export function Hero() { }); } else { toast.error("Oops!", { - description: (data as { error: string }).error || "Something went wrong. Please try again.", + description: + (data as { error: string }).error || + "Something went wrong. Please try again.", }); } } catch (error) { @@ -98,7 +100,13 @@ export function Hero() { return ( - + - A simple but powerful video editor that gets the job done. Works on any platform. + A simple but powerful video editor that gets the job done. Works on + any platform. - - + + - - {isSubmitting ? "Joining..." : "Join waitlist"} + + + {isSubmitting ? "Joining..." : "Join waitlist"} + + + + 50k+ people already joined + ); diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index c1f81e16..b15e8df8 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -2,6 +2,11 @@ import { AspectRatio } from "@/components/ui/aspect-ratio"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { ReactNode, useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; @@ -139,7 +144,12 @@ export function DraggableMediaItem({ {preview} - {showPlusOnDrag && } + {showPlusOnDrag && ( + + )} , @@ -149,8 +159,16 @@ export function DraggableMediaItem({ ); } -function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) { - return ( +function PlusButton({ + className, + onClick, + tooltipText, +}: { + className?: string; + onClick?: () => void; + tooltipText?: string; +}) { + const button = ( ); + + if (tooltipText) { + return ( + + {button} + + {tooltipText} + + + ); + } + + return button; } diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts new file mode 100644 index 00000000..86190c94 --- /dev/null +++ b/apps/web/src/hooks/use-keyboard-shortcuts.ts @@ -0,0 +1,349 @@ +"use client"; + +import { useEffect, useCallback } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { toast } from "sonner"; + +export interface KeyboardShortcut { + id: string; + keys: string[]; + description: string; + category: string; + action: () => void; + enabled?: boolean; + requiresSelection?: boolean; + icon?: React.ReactNode; +} + +interface UseKeyboardShortcutsOptions { + enabled?: boolean; + context?: "global" | "timeline" | "editor"; +} + +export const useKeyboardShortcuts = ( + options: UseKeyboardShortcutsOptions = {} +) => { + const { enabled = true, context = "editor" } = options; + + const { + tracks, + selectedElements, + clearSelectedElements, + setSelectedElements, + removeElementFromTrack, + splitElement, + addElementToTrack, + snappingEnabled, + toggleSnapping, + undo, + redo, + } = useTimelineStore(); + + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + + const { activeProject } = useProjectStore(); + + // Check if user is typing in an input field + const isInputFocused = useCallback(() => { + const activeElement = document.activeElement as HTMLElement; + return ( + activeElement && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + activeElement.contentEditable === "true") + ); + }, []); + + // Define all shortcuts in one place + const shortcuts: KeyboardShortcut[] = [ + // Playback Controls + { + id: "play-pause", + keys: ["Space"], + description: "Play/Pause", + category: "Playback", + action: () => { + toggle(); + toast.info(isPlaying ? "Paused" : "Playing", { duration: 1000 }); + }, + }, + { + id: "rewind", + keys: ["j", "J"], + description: "Rewind 1 second", + category: "Playback", + action: () => { + seek(Math.max(0, currentTime - 1)); + toast.info("Rewind 1s", { duration: 1000 }); + }, + }, + { + id: "play-pause-alt", + keys: ["k", "K"], + description: "Play/Pause (alternative)", + category: "Playback", + action: () => { + toggle(); + toast.info(isPlaying ? "Paused" : "Playing", { duration: 1000 }); + }, + }, + { + id: "fast-forward", + keys: ["l", "L"], + description: "Fast forward 1 second", + category: "Playback", + action: () => { + seek(Math.min(duration, currentTime + 1)); + toast.info("Forward 1s", { duration: 1000 }); + }, + }, + + // Navigation + { + id: "frame-backward", + keys: ["ArrowLeft"], + description: "Frame step backward", + category: "Navigation", + action: () => { + const projectFps = activeProject?.fps || 30; + seek(Math.max(0, currentTime - 1 / projectFps)); + }, + }, + { + id: "frame-forward", + keys: ["ArrowRight"], + description: "Frame step forward", + category: "Navigation", + action: () => { + const projectFps = activeProject?.fps || 30; + seek(Math.min(duration, currentTime + 1 / projectFps)); + }, + }, + { + id: "jump-backward", + keys: ["Shift+ArrowLeft"], + description: "Jump back 5 seconds", + category: "Navigation", + action: () => { + seek(Math.max(0, currentTime - 5)); + }, + }, + { + id: "jump-forward", + keys: ["Shift+ArrowRight"], + description: "Jump forward 5 seconds", + category: "Navigation", + action: () => { + seek(Math.min(duration, currentTime + 5)); + }, + }, + { + id: "goto-start", + keys: ["Home"], + description: "Go to timeline start", + category: "Navigation", + action: () => { + seek(0); + toast.info("Start of timeline", { duration: 1000 }); + }, + }, + { + id: "goto-end", + keys: ["End"], + description: "Go to timeline end", + category: "Navigation", + action: () => { + seek(duration); + toast.info("End of timeline", { duration: 1000 }); + }, + }, + + // Editing + { + id: "split-element", + keys: ["s", "S"], + description: "Split element at playhead", + category: "Editing", + requiresSelection: true, + action: () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to split"); + return; + } + + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t: any) => t.id === trackId); + const element = track?.elements.find((el: any) => el.id === elementId); + + if (element) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + splitElement(trackId, elementId, currentTime); + toast.success("Element split at playhead"); + } else { + toast.error("Playhead must be within selected element"); + } + } + }, + }, + { + id: "delete-elements", + keys: ["Delete", "Backspace"], + description: "Delete selected elements", + category: "Editing", + requiresSelection: true, + action: () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach( + ({ trackId, elementId }: { trackId: string; elementId: string }) => { + removeElementFromTrack(trackId, elementId); + } + ); + clearSelectedElements(); + }, + }, + { + id: "toggle-snapping", + keys: ["n", "N"], + description: "Toggle snapping", + category: "Editing", + action: () => { + toggleSnapping(); + toast.info(`Snapping ${snappingEnabled ? "disabled" : "enabled"}`, { + duration: 1000, + }); + }, + }, + + // Selection & Organization + { + id: "select-all", + keys: ["Cmd+a", "Ctrl+a"], + description: "Select all elements", + category: "Selection", + action: () => { + const allElements = tracks.flatMap((track: any) => + track.elements.map((element: any) => ({ + trackId: track.id, + elementId: element.id, + })) + ); + setSelectedElements(allElements); + toast.info(`Selected ${allElements.length} elements`, { + duration: 1000, + }); + }, + }, + { + id: "duplicate-element", + keys: ["Cmd+d", "Ctrl+d"], + description: "Duplicate selected element", + category: "Selection", + requiresSelection: true, + action: () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to duplicate"); + return; + } + + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t: any) => t.id === trackId); + const element = track?.elements.find((el: any) => el.id === elementId); + + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + + toast.success("Element duplicated"); + } + }, + }, + + // History + { + id: "undo", + keys: ["Cmd+z", "Ctrl+z"], + description: "Undo", + category: "History", + action: () => { + undo(); + }, + }, + { + id: "redo", + keys: ["Cmd+Shift+z", "Ctrl+Shift+z", "Cmd+y", "Ctrl+y"], + description: "Redo", + category: "History", + action: () => { + redo(); + }, + }, + ]; + + // Parse keyboard event to match against shortcuts + const parseKeyboardEvent = useCallback((e: KeyboardEvent): string => { + const parts: string[] = []; + + if (e.metaKey || e.ctrlKey) parts.push(e.metaKey ? "Cmd" : "Ctrl"); + if (e.shiftKey) parts.push("Shift"); + if (e.altKey) parts.push("Alt"); + + parts.push(e.key); + + return parts.join("+"); + }, []); + + // Handle keyboard events + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!enabled || isInputFocused()) return; + + const keyCombo = parseKeyboardEvent(e); + const shortcut = shortcuts.find((s) => + s.keys.some((key) => key === keyCombo || key === e.key) + ); + + if (shortcut) { + // Check if shortcut requires selection + if (shortcut.requiresSelection && selectedElements.length === 0) { + return; + } + + e.preventDefault(); + shortcut.action(); + } + }, + [enabled, shortcuts, selectedElements, parseKeyboardEvent, isInputFocused] + ); + + // Set up event listener + useEffect(() => { + if (!enabled) return; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [enabled, handleKeyDown]); + + // Return shortcuts for help component + return { + shortcuts: shortcuts.filter((s) => s.enabled !== false), + enabled, + }; +}; diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts index 1191d4b4..ecbd1232 100644 --- a/apps/web/src/hooks/use-playback-controls.ts +++ b/apps/web/src/hooks/use-playback-controls.ts @@ -106,68 +106,4 @@ export const usePlaybackControls = () => { separateAudio(trackId, elementId); }, [selectedElements, tracks, separateAudio]); - - const handleKeyPress = useCallback( - (e: KeyboardEvent) => { - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement - ) { - return; - } - - switch (e.key) { - case " ": - e.preventDefault(); - if (isPlaying) { - pause(); - } else { - play(); - } - break; - - case "s": - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - handleSplitSelectedElement(); - } - break; - - case "q": - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - handleSplitAndKeepLeftCallback(); - } - break; - - case "w": - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - handleSplitAndKeepRightCallback(); - } - break; - - case "d": - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - handleSeparateAudioCallback(); - } - break; - } - }, - [ - isPlaying, - play, - pause, - handleSplitSelectedElement, - handleSplitAndKeepLeftCallback, - handleSplitAndKeepRightCallback, - handleSeparateAudioCallback, - ] - ); - - useEffect(() => { - document.addEventListener("keydown", handleKeyPress); - return () => document.removeEventListener("keydown", handleKeyPress); - }, [handleKeyPress]); }; diff --git a/apps/web/src/lib/waitlist.ts b/apps/web/src/lib/waitlist.ts index 7b67258b..22b15b78 100644 --- a/apps/web/src/lib/waitlist.ts +++ b/apps/web/src/lib/waitlist.ts @@ -1,5 +1,4 @@ -import { db, sql } from "@opencut/db"; -import { waitlist } from "@opencut/db/schema"; +import { db, sql, waitlist } from "@opencut/db"; export async function getWaitlistCount() { try { diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 57e1e808..1f229e6d 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -10,7 +10,7 @@ export async function middleware(request: NextRequest) { const path = request.nextUrl.pathname; - if (path === "/editor" && env.NODE_ENV === "production") { + if (path.startsWith("/editor") && env.NODE_ENV === "production") { const homeUrl = new URL("/", request.url); homeUrl.searchParams.set("redirect", request.url); return NextResponse.redirect(homeUrl); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index e0bb39d0..e291637a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,7 +12,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", @@ -34,9 +34,10 @@ "**/*.tsx", "apps/web/.next/types/**/*.ts", "next-env.d.ts", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + "src/types/**/*.d.ts" ], "exclude": [ "node_modules" ] -} +}
{tooltipText}