From 130bed1d1fcf8196e02f293af44afcaa1a3d66ac Mon Sep 17 00:00:00 2001 From: Jack Mechem Date: Thu, 21 May 2026 16:35:34 -0700 Subject: [PATCH] Dev console --- app/api/dev/proxy/route.ts | 33 + app/components/DevConsole.tsx | 481 ++++ app/components/NavBar.tsx | 49 +- app/page.tsx | 169 +- pnpm-lock.yaml | 4118 +++++++++++++++++++++++++++++++++ 5 files changed, 4790 insertions(+), 60 deletions(-) create mode 100644 app/api/dev/proxy/route.ts create mode 100644 app/components/DevConsole.tsx create mode 100644 pnpm-lock.yaml diff --git a/app/api/dev/proxy/route.ts b/app/api/dev/proxy/route.ts new file mode 100644 index 0000000..38cf1de --- /dev/null +++ b/app/api/dev/proxy/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const token = req.cookies.get("token")?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { method, url, body } = (await req.json()) as { + method: string; + url: string; + body?: unknown; + }; + + const targetUrl = url.startsWith("http") + ? url + : `http://localhost:3001${url.startsWith("/") ? url : `/${url}`}`; + + const res = await fetch(targetUrl, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { "Content-Type": res.headers.get("Content-Type") ?? "text/plain" }, + }); +} diff --git a/app/components/DevConsole.tsx b/app/components/DevConsole.tsx new file mode 100644 index 0000000..e5024d4 --- /dev/null +++ b/app/components/DevConsole.tsx @@ -0,0 +1,481 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { LuX, LuTerminal, LuSend } from "react-icons/lu"; + +export interface LogEntry { + id: number; + method: string; + path: string; + url: string; + status: number | null; + duration: number | null; + timestamp: string; + response: string | null; +} + +interface DevConsoleProps { + open: boolean; + width: number; + isMobile: boolean; + onClose: () => void; + onWidthChange: (w: number) => void; + logs: LogEntry[]; +} + +function tryPretty(text: string): string { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } +} + +function methodBg(method: string): string { + switch (method) { + case "GET": return "#1d4ed8"; + case "POST": return "#15803d"; + case "PUT": return "#b45309"; + case "PATCH": return "#6d28d9"; + case "DELETE": return "#b91c1c"; + default: return "#475569"; + } +} + +export default function DevConsole({ + open, width, isMobile, onClose, onWidthChange, logs, +}: DevConsoleProps) { + const [activeTab, setActiveTab] = useState<"logs" | "request">("logs"); + const [expandedId, setExpandedId] = useState(null); + const [reqMethod, setReqMethod] = useState("GET"); + const [reqUrl, setReqUrl] = useState("/api/power"); + const [reqBody, setReqBody] = useState(""); + const [reqResponse, setReqResponse] = useState<{ status: number; body: string } | null>(null); + const [reqLoading, setReqLoading] = useState(false); + + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null); + + useEffect(() => { + const onMove = (e: MouseEvent) => { + if (!dragRef.current) return; + const delta = dragRef.current.startX - e.clientX; + const next = Math.max(300, Math.min(900, dragRef.current.startWidth + delta)); + onWidthChange(next); + }; + const onUp = () => { + if (!dragRef.current) return; + dragRef.current = null; + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + }, [onWidthChange]); + + async function sendRequest() { + setReqLoading(true); + setReqResponse(null); + try { + const isAbsolute = reqUrl.startsWith("http://") || reqUrl.startsWith("https://"); + let fetchUrl: string; + let fetchInit: RequestInit; + + if (isAbsolute) { + fetchUrl = "/api/dev/proxy"; + fetchInit = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: reqMethod, url: reqUrl, body: reqBody || undefined }), + }; + } else { + fetchUrl = reqUrl; + fetchInit = { + method: reqMethod, + headers: reqBody ? { "Content-Type": "application/json" } : {}, + body: ["POST", "PUT", "PATCH"].includes(reqMethod) && reqBody ? reqBody : undefined, + }; + } + + const res = await fetch(fetchUrl, fetchInit); + const text = await res.text(); + setReqResponse({ status: res.status, body: tryPretty(text) }); + } catch (e) { + setReqResponse({ status: 0, body: String(e) }); + } finally { + setReqLoading(false); + } + } + + const panelWidth = isMobile ? "100%" : `${width}px`; + const panelLeft = isMobile ? "0" : "auto"; + + return ( +
+ {/* Drag handle — desktop only */} + {!isMobile && ( +
{ + e.preventDefault(); + dragRef.current = { startX: e.clientX, startWidth: width }; + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + }} + /> + )} + + {/* Header */} +
+ + + Dev Console + + +
+ {(["logs", "request"] as const).map((tab) => ( + + ))} + +
+
+ + {/* Logs tab */} + {activeTab === "logs" && ( +
+ {logs.length === 0 ? ( +
+ No requests captured yet +
+ ) : ( + [...logs].reverse().map((entry) => ( + setExpandedId(expandedId === entry.id ? null : entry.id)} + /> + )) + )} +
+ )} + + {/* Request tab */} + {activeTab === "request" && ( +
+
+
+ + setReqUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendRequest()} + placeholder="/api/power" + style={{ + flex: 1, + background: "#1e293b", + border: "1px solid #334155", + borderRadius: "6px", + color: "#e2e8f0", + fontSize: "0.68rem", + padding: "5px 8px", + fontFamily: "ui-monospace, monospace", + outline: "none", + minWidth: 0, + }} + /> + +
+ + {["POST", "PUT", "PATCH"].includes(reqMethod) && ( +