diff --git a/src/main.jsx b/src/main.jsx index 9f28c8b..2a6b667 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { Check, @@ -19,6 +19,7 @@ import { import "./styles.css"; const DEFAULT_API_URL = "/api"; +const API_HISTORY_LIMIT = 80; function readStored(key, fallback) { try { @@ -87,6 +88,8 @@ function App() { const [status, setStatus] = useState({ tone: "idle", message: "Ready" }); const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(false); + const [apiHistory, setApiHistory] = useState([]); + const apiHistoryEndRef = useRef(null); const normalizedApiUrl = useMemo(() => normalizeApiUrl(apiUrl), [apiUrl]); const selectedNote = useMemo( @@ -122,11 +125,22 @@ function App() { } }, [token]); + useEffect(() => { + apiHistoryEndRef.current?.scrollIntoView({ block: "end" }); + }, [apiHistory.length]); + function setMessage(tone, message) { setStatus({ tone, message }); } + function recordApiCall(entry) { + setApiHistory((current) => [...current, entry].slice(-API_HISTORY_LIMIT)); + } + async function apiRequest(path, options = {}) { + const method = options.method || "GET"; + const url = `${normalizedApiUrl}${path}`; + const startedAt = performance.now(); const headers = { Accept: "application/json", ...(options.body ? { "Content-Type": "application/json" } : {}), @@ -134,20 +148,56 @@ function App() { ...(options.headers || {}), }; - const response = await fetch(`${normalizedApiUrl}${path}`, { - ...options, - headers, - }); + try { + const response = await fetch(url, { + ...options, + headers, + }); - const text = await response.text(); - const payload = text ? parsePayload(text) : null; + const text = await response.text(); + const payload = text ? parsePayload(text) : null; + const elapsedMs = Math.round(performance.now() - startedAt); - if (!response.ok) { - const message = payload?.message || payload?.error || text || `${response.status} ${response.statusText}`; - throw new Error(message); + recordApiCall({ + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + method, + path, + url, + ok: response.ok, + status: response.status, + statusText: response.statusText, + elapsedMs, + timestamp: new Date().toLocaleTimeString(), + request: formatBody(options.body), + response: payload, + }); + + if (!response.ok) { + const message = payload?.message || payload?.error || text || `${response.status} ${response.statusText}`; + const httpError = new Error(message); + httpError.apiLogged = true; + throw httpError; + } + + return payload; + } catch (error) { + if (!error?.apiLogged) { + recordApiCall({ + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + method, + path, + url, + ok: false, + status: "ERR", + statusText: "Network error", + elapsedMs: Math.round(performance.now() - startedAt), + timestamp: new Date().toLocaleTimeString(), + request: formatBody(options.body), + response: { error: error.message }, + }); + } + throw error; } - - return payload; } async function handleAuth(event) { @@ -478,6 +528,12 @@ function App() {

Point the app at the API, register or log in, then create, edit, delete, and share notes.

)} + + setApiHistory([])} + endRef={apiHistoryEndRef} + /> ); @@ -531,6 +587,53 @@ function StatusBadge({ status }) { return
{status.message}
; } +function ApiHistoryPanel({ entries, onClear, endRef }) { + return ( +
+
+
+

REST history

+

Requests and responses

+
+ +
+ +
+ {entries.length ? ( + entries.map((entry) => ( +
+
+ {entry.method} + {entry.path} + + {entry.status} {entry.statusText} + + + {entry.elapsedMs}ms +
+
+
+ Request +
{formatDisplay(entry.request)}
+
+
+ Response +
{formatDisplay(entry.response)}
+
+
+
+ )) + ) : ( +

REST calls will appear here.

+ )} +
+
+
+ ); +} + function parsePayload(text) { try { return JSON.parse(text); @@ -539,6 +642,37 @@ function parsePayload(text) { } } +function formatBody(body) { + if (!body) return null; + if (typeof body !== "string") return redactSensitive(body); + try { + return redactSensitive(JSON.parse(body)); + } catch { + return body; + } +} + +function formatDisplay(value) { + if (value === null || value === undefined || value === "") return "No body"; + if (typeof value === "string") return value; + return JSON.stringify(redactSensitive(value), null, 2); +} + +function redactSensitive(value) { + if (Array.isArray(value)) return value.map(redactSensitive); + if (!value || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value).map(([key, child]) => { + const lowerKey = key.toLowerCase(); + if (lowerKey.includes("password") || lowerKey.includes("token") || lowerKey.includes("authorization")) { + return [key, "redacted"]; + } + return [key, redactSensitive(child)]; + }), + ); +} + function normalizeApiUrl(value) { const trimmed = value.trim(); if (!trimmed || trimmed === "/") return ""; diff --git a/src/styles.css b/src/styles.css index d9674fa..333fb8f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -17,6 +17,7 @@ body { margin: 0; min-width: 320px; min-height: 100vh; + overflow: hidden; } button, @@ -39,7 +40,8 @@ textarea:disabled { .app-shell { display: grid; grid-template-columns: minmax(300px, 380px) 1fr; - min-height: 100vh; + height: 100vh; + min-height: 0; background: linear-gradient(90deg, #efe7db 0, #f5f2eb 34%, #eef5f0 100%); } @@ -48,7 +50,8 @@ textarea:disabled { display: flex; flex-direction: column; gap: 18px; - min-height: 100vh; + min-height: 0; + overflow-y: auto; padding: 24px; border-right: 1px solid rgba(40, 43, 46, 0.12); background: rgba(250, 248, 243, 0.78); @@ -82,7 +85,8 @@ textarea:disabled { } h1, -h2 { +h2, +h3 { margin: 0; letter-spacing: 0; } @@ -99,6 +103,11 @@ h2 { line-height: 1; } +h3 { + font-size: 1.02rem; + line-height: 1.1; +} + .panel { border: 1px solid rgba(42, 44, 46, 0.13); border-radius: 8px; @@ -353,10 +362,13 @@ textarea:focus { } .workspace { - display: flex; - flex-direction: column; - gap: 22px; + display: grid; + grid-template-rows: auto minmax(0, 1fr) minmax(220px, 30vh); + gap: 18px; + height: 100vh; min-width: 0; + min-height: 0; + overflow: hidden; padding: 34px; } @@ -387,9 +399,14 @@ textarea:focus { .editor { display: flex; - flex: 1; flex-direction: column; gap: 14px; + min-height: 0; +} + +.editor textarea { + flex: 1; + min-height: 0; } .title-input { @@ -424,7 +441,7 @@ textarea:focus { display: grid; place-items: center; align-content: center; - min-height: 60vh; + min-height: 0; color: #4c5550; text-align: center; } @@ -439,6 +456,139 @@ textarea:focus { line-height: 1.55; } +.api-history { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid rgba(42, 44, 46, 0.13); + border-radius: 8px; + background: rgba(255, 255, 255, 0.72); + box-shadow: 0 16px 48px rgba(54, 49, 40, 0.07); + overflow: hidden; +} + +.api-history-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border-bottom: 1px solid rgba(42, 44, 46, 0.11); +} + +.api-history-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 10px; +} + +.api-entry { + display: grid; + gap: 9px; + padding: 10px; + border: 1px solid rgba(42, 44, 46, 0.1); + border-radius: 8px; + background: rgba(250, 248, 243, 0.8); +} + +.api-entry + .api-entry { + margin-top: 8px; +} + +.api-entry-summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto auto; + align-items: center; + gap: 8px; + color: #58625d; + font-size: 0.78rem; + font-weight: 760; +} + +.api-entry-summary code { + min-width: 0; + overflow: hidden; + color: #1f2d29; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + +.method, +.api-status { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; + white-space: nowrap; +} + +.method { + color: #17332e; + background: #dcefe8; +} + +.method-post, +.method-put { + color: #173042; + background: #dbe8f0; +} + +.method-delete { + color: #8f2d28; + background: #f7ded8; +} + +.api-status.ok { + color: #135b4b; + background: #e1f2ea; +} + +.api-status.error { + color: #8f2d28; + background: #f7ded8; +} + +.api-entry-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.api-entry-details strong { + display: block; + margin-bottom: 5px; + color: #4c5550; + font-size: 0.75rem; + text-transform: uppercase; +} + +.api-entry-details pre { + max-height: 130px; + margin: 0; + overflow: auto; + padding: 9px; + border-radius: 7px; + background: #18211f; + color: #eff7f2; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.76rem; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.api-history-empty { + margin: 56px 0; + color: #6c756f; + text-align: center; +} + .spin { animation: spin 900ms linear infinite; } @@ -450,8 +600,14 @@ textarea:focus { } @media (max-width: 840px) { + body { + overflow: auto; + } + .app-shell { grid-template-columns: 1fr; + height: auto; + min-height: 100vh; } .sidebar { @@ -465,13 +621,26 @@ textarea:focus { } .workspace { + grid-template-rows: auto; + height: auto; + overflow: visible; padding: 22px; } + .api-history { + min-height: 220px; + max-height: 36vh; + } + .workspace-header { align-items: flex-start; flex-direction: column; } + + .api-entry-summary, + .api-entry-details { + grid-template-columns: 1fr; + } } @media (max-width: 520px) {