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) {