diff --git a/app/api/trajectory/route.js b/app/api/trajectory/route.js new file mode 100644 index 0000000..5912aa7 --- /dev/null +++ b/app/api/trajectory/route.js @@ -0,0 +1,29 @@ +import fs from 'fs'; +import path from 'path'; + +// File JSON akan disimpan di root direktori proyek Next.js +const filePath = path.join(process.cwd(), 'perjalanan.json'); + +// Menerima data dari Tombol Y (Save) +export async function POST(req) { + try { + const data = await req.json(); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + return Response.json({ success: true, message: 'Data perjalanan berhasil disimpan!' }); + } catch (error) { + return Response.json({ success: false, error: 'Gagal menyimpan data' }, { status: 500 }); + } +} + +// Membaca data untuk Tombol A & X (Playback) +export async function GET() { + try { + if (!fs.existsSync(filePath)) { + return Response.json([]); // Kembalikan array kosong jika belum ada rekaman + } + const fileData = fs.readFileSync(filePath, 'utf-8'); + return Response.json(JSON.parse(fileData)); + } catch (error) { + return Response.json({ success: false, error: 'Gagal membaca data' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..92d0809 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,20 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter, Roboto_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const robotoMono = Roboto_Mono({ + variable: "--font-roboto-mono", subsets: ["latin"], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Robot UI", + description: "Robot Vision Control Dashboard", }; export default function RootLayout({ @@ -25,9 +25,9 @@ export default function RootLayout({ return ( {children} ); -} +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 298c71e..750841b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,35 +2,289 @@ import { useState, useEffect, useRef } from 'react'; +// ============================================================ +// HELPER +// ============================================================ +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +// ============================================================ +// KONFIGURASI +// ============================================================ +const RECORD_INTERVAL_MS = 50; +const DIRECTION_BRAKE_MS = 600; +const MAX_STEP_TIMEOUT_MS = 15000; +const LATENCY_COMPENSATION_TICKS = 3; + +// ============================================================ +// TYPE +// ============================================================ +type Steering = { + x: number; + y: number; +}; + +type MotionType = + | 'FORWARD' + | 'REVERSE' + | 'TURN_LEFT' + | 'TURN_RIGHT' + | 'STOP'; + +type TrajectoryStep = { + x: number; + y: number; + target_ticks_l: number; + target_ticks_r: number; + motion?: MotionType; + sample_ms?: number; +}; + export default function RobotDashboard() { - const PI_IP = '172.17.172.16'; + const PI_IP = '192.168.0.105'; const streamUrl = `http://${PI_IP}:8080/stream`; - const [controlMode, setControlMode] = useState('gamepad'); - const [gamepadStatus, setGamepadStatus] = useState("Menunggu Gamepad..."); + const [controlMode, setControlMode] = useState('touch'); + const [gamepadStatus, setGamepadStatus] = useState('Menunggu Gamepad...'); const [steering, setSteering] = useState({ x: 0, y: 0 }); + const [ticks, setTicks] = useState({ l: 0, r: 0 }); + const [isRecordingUI, setIsRecordingUI] = useState(false); + const [isPlayingUI, setIsPlayingUI] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); // State Fullscreen + + const currentTicksRef = useRef({ l: 0, r: 0 }); + const lastRecordedTicksRef = useRef({ l: 0, r: 0 }); + const activeMotionRef = useRef('STOP'); + const lastCommandRef = useRef({ x: 0, y: 0 }); - const wsRef = useRef(null); + const isRecordingRef = useRef(false); + const isPlayingRef = useRef(false); + const isSavingRef = useRef(false); + const trajectoryRef = useRef([]); + const prevBtnsRef = useRef({ A: false, B: false, Y: false, X: false }); + const wsRef = useRef(null); const keysRef = useRef({ up: false, down: false, left: false, right: false }); - const forceScanGamepad = () => { - if (typeof navigator !== "undefined" && navigator.getGamepads) { - const gamepads = navigator.getGamepads(); - let activeGp = null; - for (let i = 0; i < gamepads.length; i++) { - if (gamepads[i] !== null) { - activeGp = gamepads[i]; - break; + const touchStateRef = useRef({ turn: 0, throttle: 0 }); + + const sendCommand = (x: number, y: number) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ x, y })); + } + }; + + const stopRobot = () => { + setSteering({ x: 0, y: 0 }); + sendCommand(0, 0); + }; + + const getMotionType = (x: number, y: number): MotionType => { + const DEADZONE = 0.10; + const hasX = Math.abs(x) > DEADZONE; + const hasY = Math.abs(y) > DEADZONE; + + if (!hasX && !hasY) return 'STOP'; + if (hasY) return y > 0 ? 'FORWARD' : 'REVERSE'; + return x > 0 ? 'TURN_RIGHT' : 'TURN_LEFT'; + }; + + const executePlayback = async (reverse: boolean = false) => { + if (isPlayingRef.current || isSavingRef.current) return; + + isPlayingRef.current = true; + setIsPlayingUI(true); + console.log(`Memulai Playback ${reverse ? 'REVERSE' : 'FORWARD'}...`); + + try { + const res = await fetch(`/api/trajectory?_t=${Date.now()}`, { method: 'GET', cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP Error ${res.status}`); + const data = (await res.json()) as TrajectoryStep[]; + + if (!Array.isArray(data) || data.length === 0) { + alert('Data perjalanan kosong! Rekam rute terlebih dahulu.'); + return; + } + + const routeData = reverse ? [...data].reverse() : data; + let lastActiveMotion: MotionType = 'STOP'; + + for (let i = 0; i < routeData.length; i++) { + if (!isPlayingRef.current) break; + + const step = routeData[i]; + const targetX = reverse ? -step.x : step.x; + const targetY = reverse ? -step.y : step.y; + const currentMotion = getMotionType(targetX, targetY); + + if (currentMotion !== 'STOP' && lastActiveMotion !== 'STOP') { + const isReversal = + (lastActiveMotion === 'FORWARD' && currentMotion === 'REVERSE') || + (lastActiveMotion === 'REVERSE' && currentMotion === 'FORWARD') || + (lastActiveMotion === 'TURN_LEFT' && currentMotion === 'TURN_RIGHT') || + (lastActiveMotion === 'TURN_RIGHT' && currentMotion === 'TURN_LEFT'); + + if (isReversal) { + stopRobot(); + await sleep(DIRECTION_BRAKE_MS); + if (!isPlayingRef.current) break; + } + } + + if (currentMotion !== 'STOP') lastActiveMotion = currentMotion; + + setSteering({ x: targetX, y: targetY }); + sendCommand(targetX, targetY); + + const startL = currentTicksRef.current.l; + const startR = currentTicksRef.current.r; + + const targetL = Math.max(0, Number(step.target_ticks_l || 0)); + const targetR = Math.max(0, Number(step.target_ticks_r || 0)); + + const compTargetL = Math.max(0, targetL - LATENCY_COMPENSATION_TICKS); + const compTargetR = Math.max(0, targetR - LATENCY_COMPENSATION_TICKS); + + if (targetL === 0 && targetR === 0) { + await sleep(RECORD_INTERVAL_MS); + continue; + } + + const stepStartTime = Date.now(); + + while (isPlayingRef.current) { + const currentDeltaL = Math.abs(currentTicksRef.current.l - startL); + const currentDeltaR = Math.abs(currentTicksRef.current.r - startR); + let stepFinished = false; + + if (targetL > targetR) { + stepFinished = currentDeltaL >= compTargetL; + } else if (targetR > targetL) { + stepFinished = currentDeltaR >= compTargetR; + } else { + stepFinished = currentDeltaL >= compTargetL || currentDeltaR >= compTargetR; + } + + if (stepFinished) break; + + if (Date.now() - stepStartTime > MAX_STEP_TIMEOUT_MS) { + console.warn(`Timeout step ${i}: Robot tersangkut.`); + break; + } + await sleep(10); } } - if (activeGp) setGamepadStatus(`🟢 TERHUBUNG: ${activeGp.id.substring(0, 15)}...`); + console.log('Playback selesai!'); + } catch (error) { + console.error('Gagal playback:', error); + } finally { + stopRobot(); + isPlayingRef.current = false; + setIsPlayingUI(false); + } + }; + + const forceScanGamepad = () => { + if (typeof navigator === 'undefined' || !navigator.getGamepads) return; + const gamepads = navigator.getGamepads(); + let activeGp: Gamepad | null = null; + for (let i = 0; i < gamepads.length; i++) { + if (gamepads[i]) { activeGp = gamepads[i]; break; } + } + if (activeGp) setGamepadStatus(`🟢 TERHUBUNG: ${activeGp.id.substring(0, 15)}...`); + }; + + const saveTrajectory = async () => { + isSavingRef.current = true; + try { + stopRobot(); + + const deltaL = Math.abs(currentTicksRef.current.l - lastRecordedTicksRef.current.l); + const deltaR = Math.abs(currentTicksRef.current.r - lastRecordedTicksRef.current.r); + if ((deltaL > 0 || deltaR > 0) && activeMotionRef.current !== 'STOP') { + trajectoryRef.current.push({ + x: lastCommandRef.current.x, + y: lastCommandRef.current.y, + target_ticks_l: deltaL, + target_ticks_r: deltaR, + motion: activeMotionRef.current, + }); + } + + const trajectoryToSave = [...trajectoryRef.current]; + if (trajectoryToSave.length === 0) return; + + const res = await fetch('/api/trajectory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(trajectoryToSave), + }); + + if (!res.ok) throw new Error(`HTTP Error ${res.status}`); + } catch (error) { + alert('Gagal menyimpan rute.'); + } finally { + isSavingRef.current = false; + } + }; + + const handleTouchAction = (action: 'RECORD' | 'SAVE' | 'PLAY_FWD' | 'PLAY_REV') => { + if (action === 'PLAY_FWD') { + if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } + else if (!isSavingRef.current && !isRecordingRef.current) executePlayback(false); + } else if (action === 'PLAY_REV') { + if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } + else if (!isSavingRef.current && !isRecordingRef.current) executePlayback(true); + } else if (action === 'RECORD') { + if (!isRecordingRef.current && !isPlayingRef.current && !isSavingRef.current) { + isRecordingRef.current = true; + setIsRecordingUI(true); + trajectoryRef.current = []; + lastRecordedTicksRef.current = { l: currentTicksRef.current.l, r: currentTicksRef.current.r }; + activeMotionRef.current = 'STOP'; + lastCommandRef.current = { x: 0, y: 0 }; + stopRobot(); + } + } else if (action === 'SAVE') { + if (isRecordingRef.current) { + isRecordingRef.current = false; + setIsRecordingUI(false); + saveTrajectory(); + } + } + }; + + // Fungsi Full-Screen + const enterFullscreen = () => { + const elem = document.documentElement; + if (elem.requestFullscreen) { + elem.requestFullscreen(); + } + }; + + const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen(); } }; useEffect(() => { + // Sinkronisasi status fullscreen dari sistem + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', handleFullscreenChange); + const ws = new WebSocket(`ws://${PI_IP}:8765`); - ws.onopen = () => console.log("WebSocket Terhubung ke Pi!"); - ws.onclose = () => console.log("WebSocket Terputus."); + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.ticks_l !== undefined && data.ticks_r !== undefined) { + const newTicks = { l: Number(data.ticks_l), r: Number(data.ticks_r) }; + setTicks(newTicks); + currentTicksRef.current = newTicks; + } + } catch (error) {} + }; wsRef.current = ws; const handleKeyDown = (e: KeyboardEvent) => { @@ -39,151 +293,400 @@ export default function RobotDashboard() { if (['ArrowLeft', 'a', 'A'].includes(e.key)) keysRef.current.left = true; if (['ArrowRight', 'd', 'D'].includes(e.key)) keysRef.current.right = true; }; - const handleKeyUp = (e: KeyboardEvent) => { if (['ArrowUp', 'w', 'W'].includes(e.key)) keysRef.current.up = false; if (['ArrowDown', 's', 'S'].includes(e.key)) keysRef.current.down = false; if (['ArrowLeft', 'a', 'A'].includes(e.key)) keysRef.current.left = false; if (['ArrowRight', 'd', 'D'].includes(e.key)) keysRef.current.right = false; }; - window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); const interval = setInterval(() => { - let currentX = 0; - let currentY = 0; + let currentX = 0; let currentY = 0; + let btnA = false; let btnB = false; let btnY = false; let btnX = false; if (controlMode === 'gamepad') { - if (typeof navigator !== "undefined" && navigator.getGamepads) { + if (typeof navigator !== 'undefined' && navigator.getGamepads) { const gamepads = navigator.getGamepads(); - let activeGp = null; + let activeGp: Gamepad | null = null; for (let i = 0; i < gamepads.length; i++) { - if (gamepads[i] !== null) { - activeGp = gamepads[i]; - break; - } + if (gamepads[i]) { activeGp = gamepads[i]; break; } } - if (activeGp) { - const rawX = activeGp.axes[0]; - - // SESUAI PERMINTAAN: RT(7) Maju, LT(6) Mundur - const maju = activeGp.buttons[7].value; - const mundur = activeGp.buttons[6].value; - - // PERBAIKAN 1: Karena Pi minta nilai minus untuk maju, pengurangannya dibalik (mundur - maju) - const throttle = mundur - maju; - + const rawX = activeGp.axes[0] || 0; + const maju = activeGp.buttons[7]?.value || 0; + const mundur = activeGp.buttons[6]?.value || 0; currentX = Math.abs(rawX) > 0.15 ? Number(rawX.toFixed(2)) : 0; - currentY = Number(throttle.toFixed(2)); + currentY = Number((mundur - maju).toFixed(2)); + btnA = activeGp.buttons[0]?.pressed || false; + btnB = activeGp.buttons[1]?.pressed || false; + btnY = activeGp.buttons[3]?.pressed || false; + btnX = activeGp.buttons[2]?.pressed || false; } } } else if (controlMode === 'keyboard') { - // PERBAIKAN 2: Keyboard disamakan. W/Up (maju) menghasilkan minus (-1), S/Down menghasilkan plus (+1) currentY = (keysRef.current.down ? 1 : 0) - (keysRef.current.up ? 1 : 0); currentX = (keysRef.current.right ? 1 : 0) - (keysRef.current.left ? 1 : 0); + } else if (controlMode === 'touch' || isFullscreen) { + // Kontrol layar sentuh aktif baik di mode touch biasa maupun fullscreen + currentX = touchStateRef.current.turn; + currentY = touchStateRef.current.throttle; + } + + const currentMotion = getMotionType(currentX, currentY); + + if (btnA && !prevBtnsRef.current.A && !isRecordingRef.current) { + if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } + else if (!isSavingRef.current) executePlayback(false); + } + + if (btnX && !prevBtnsRef.current.X && !isRecordingRef.current) { + if (isPlayingRef.current) { isPlayingRef.current = false; stopRobot(); } + else if (!isSavingRef.current) executePlayback(true); + } + + if (btnB && !prevBtnsRef.current.B && !isRecordingRef.current && !isPlayingRef.current && !isSavingRef.current) { + isRecordingRef.current = true; + setIsRecordingUI(true); + trajectoryRef.current = []; + lastRecordedTicksRef.current = { l: currentTicksRef.current.l, r: currentTicksRef.current.r }; + activeMotionRef.current = currentMotion; + lastCommandRef.current = { x: currentX, y: currentY }; + stopRobot(); + } + + if (btnY && !prevBtnsRef.current.Y && isRecordingRef.current) { + isRecordingRef.current = false; + setIsRecordingUI(false); + saveTrajectory(); + } + + prevBtnsRef.current = { A: btnA, B: btnB, Y: btnY, X: btnX }; + + if (isPlayingRef.current || isSavingRef.current) return; + + if (isRecordingRef.current) { + if (currentMotion !== activeMotionRef.current) { + const deltaL = Math.abs(currentTicksRef.current.l - lastRecordedTicksRef.current.l); + const deltaR = Math.abs(currentTicksRef.current.r - lastRecordedTicksRef.current.r); + + if ((deltaL > 0 || deltaR > 0) && activeMotionRef.current !== 'STOP') { + trajectoryRef.current.push({ + x: lastCommandRef.current.x, + y: lastCommandRef.current.y, + target_ticks_l: deltaL, + target_ticks_r: deltaR, + motion: activeMotionRef.current, + }); + } + + lastRecordedTicksRef.current = { l: currentTicksRef.current.l, r: currentTicksRef.current.r }; + activeMotionRef.current = currentMotion; + } + + if (currentMotion !== 'STOP') { + lastCommandRef.current = { x: currentX, y: currentY }; + } } setSteering({ x: currentX, y: currentY }); - - if (wsRef.current && wsRef.current.readyState === 1) { - wsRef.current.send(JSON.stringify({ x: currentX, y: currentY })); - } - }, 100); + sendCommand(currentX, currentY); + }, RECORD_INTERVAL_MS); return () => { clearInterval(interval); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); + document.removeEventListener('fullscreenchange', handleFullscreenChange); if (wsRef.current) wsRef.current.close(); }; - }, [controlMode, PI_IP]); + }, [controlMode, PI_IP, isFullscreen]); - // --- PERBAIKAN 3: VISUALIZER --- - const maxRadius = 36; + const maxRadius = 36; const dotX = steering.x * maxRadius; - - // Karena 'maju' nilainya sudah minus (-), dan di CSS nilai minus (-) membuat titik NAIK KE ATAS, - // maka kita HAPUS perkalian (* -1) yang sebelumnya. Sekarang titiknya akan maju sempurna! const dotY = steering.y * maxRadius; return ( -
-
-
-

Robot Vision Control

+
e.preventDefault()} + className="min-h-screen bg-slate-950 text-slate-100 p-3 md:p-8 font-sans pb-12 select-none" + style={{ WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }} + > +
+ + {/* HEADER */} +
+
+

+ ROBOT VISION +

+ {isRecordingUI && ( + + MEREKAM... + + )} + {isPlayingUI && ( + + MEMUTAR ULANG... + + )} +
-
- - +
+ + +
-
-
-

- - Live Feed -

-
- {/* eslint-disable-next-line @next/next/no-img-element */} - Robot view { - e.currentTarget.style.display = 'none'; - e.currentTarget.parentElement!.innerHTML = '
Kamera gagal dimuat.
Pastikan Pi dan HP berada dalam jaringan Tailscale/Wi-Fi yang sama.
'; - }} - /> +
+ + {/* KOLOM KIRI (KAMERA & TOUCH CONTROLLER) */} +
+ +
+
+

+ Live Feed +

+ {/* TOMBOL PEMICU FULL-SCREEN */} + +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Robot view { e.currentTarget.style.display = 'none'; }} /> +
+ + {/* DUAL-PAD SPLIT CONTROLLER UI BAWAAN */} + {controlMode === 'touch' && ( +
+ + {/* SISI KIRI: BELOK */} +
+
Steering
+
+ + +
+
+ + {/* SISI KANAN: THROTTLE */} +
+
Throttle
+
+ + +
+
+ +
+ )}
-
-

- Status Kontrol - {controlMode === 'gamepad' && ( - - )} -

- -
-
- {controlMode === 'keyboard' ? '⌨️ AKTIF: Gunakan W-A-S-D atau Panah' : gamepadStatus} -
- -
-
Visualizer (X / Y)
-
-
-
-
+ {/* KOLOM KANAN (STATUS & AKSI RUTE) */} +
+
+

+ Status Kontrol + {controlMode === 'gamepad' && ( )} +

+ +
+
+ {controlMode === 'touch' ? '📱 AKTIF: DUAL-PAD SENTUH' : controlMode === 'keyboard' ? '⌨️ AKTIF: W-A-S-D' : gamepadStatus}
-
- X: {steering.x} - Y: {steering.y} + +
+
Visualizer Posisi
+ +
+
+
+
+
+ +
+ X: {steering.x}Y: {steering.y} +
+ +
+ ⚙️ L: {ticks.l}⚙️ R: {ticks.r} +
+ + {/* TOMBOL AKSI RUTE */} +
+
Memori Rute
+
+ + + + +
+
+
+ + {/* OVERLAY LAYAR PENUH (FULL SCREEN) */} + {isFullscreen && ( +
+ + {/* Live Feed Video Background */} + {/* eslint-disable-next-line @next/next/no-img-element */} + Robot Fullscreen { e.currentTarget.style.display = 'none'; }} + /> + + {/* Tombol Keluar (Pojok Kanan Atas) */} + + + {/* AREA KONTROL OVERLAY */} +
+ + {/* KIRI: Tombol Kiri & Kanan (Steering) */} +
+ + +
+ + {/* TENGAH: 4 Tombol Aksi (D-Pad Style) */} +
+ {/* Tombol Atas (Rekam) */} + + {/* Tombol Bawah (Simpan) */} + + {/* Tombol Kiri (Play Rev) */} + + {/* Tombol Kanan (Play Fwd) */} + +
+ + {/* KANAN: Tombol Atas & Bawah (Throttle) */} +
+ + +
+ +
+
+ )} +
); } \ No newline at end of file diff --git a/next.config.ts b/next.config.mjs similarity index 60% rename from next.config.ts rename to next.config.mjs index b9ce313..687553f 100644 --- a/next.config.ts +++ b/next.config.mjs @@ -1,3 +1,5 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + output: 'export', +}; export default nextConfig; \ No newline at end of file diff --git a/perjalanan.json b/perjalanan.json new file mode 100644 index 0000000..c61efad --- /dev/null +++ b/perjalanan.json @@ -0,0 +1,9 @@ +[ + { + "x": 0, + "y": -0.6, + "target_ticks_l": 19, + "target_ticks_r": 23, + "motion": "REVERSE" + } +] \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..bf4d359 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, "include": [ @@ -28,7 +34,10 @@ "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", - "**/*.mts" + "**/*.mts", + "next.config.mjs" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] }