Initial app

This commit is contained in:
2026-06-01 15:33:47 -06:00
commit c7fc90204e
7 changed files with 2628 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
.vite.log
*.log
.DS_Store

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notes Vault</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1506
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "notes-vault-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"vite": "^8.0.16",
"typescript": "^5.9.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"lucide-react": "^0.555.0"
},
"devDependencies": {}
}

571
src/main.jsx Normal file
View File

@@ -0,0 +1,571 @@
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Check,
Copy,
KeyRound,
Lock,
LogIn,
LogOut,
Plus,
RefreshCw,
Save,
Search,
Send,
Settings,
Trash2,
UserPlus,
} from "lucide-react";
import "./styles.css";
const DEFAULT_API_URL = "/api";
function readStored(key, fallback) {
try {
return localStorage.getItem(key) || fallback;
} catch {
return fallback;
}
}
function writeStored(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// Storage can be unavailable in restricted browser contexts.
}
}
function readApiUrl() {
const stored = readStored("notes-vault-api-url", DEFAULT_API_URL);
return stored === "http://localhost:8080" ? DEFAULT_API_URL : stored;
}
function noteId(note) {
return note?.id || note?._id || note?.noteId || "";
}
function noteTitle(note) {
return note?.title || note?.name || "Untitled note";
}
function noteContent(note) {
return note?.content || note?.body || note?.text || "";
}
function noteOwner(note) {
return note?.owner || note?.ownerId || note?.createdBy || note?.userId || "";
}
function extractToken(payload) {
return (
payload?.token ||
payload?.jwt ||
payload?.accessToken ||
payload?.access_token ||
payload?.data?.token ||
payload?.data?.accessToken ||
""
);
}
function extractUser(payload, fallback) {
return payload?.user?.username || payload?.user?.email || payload?.username || payload?.email || fallback;
}
function App() {
const [apiUrl, setApiUrl] = useState(readApiUrl);
const [token, setToken] = useState(() => readStored("notes-vault-token", ""));
const [currentUser, setCurrentUser] = useState(() => readStored("notes-vault-user", ""));
const [authMode, setAuthMode] = useState("login");
const [authForm, setAuthForm] = useState({ username: "", password: "" });
const [notes, setNotes] = useState([]);
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState({ title: "", content: "" });
const [query, setQuery] = useState("");
const [shareRecipient, setShareRecipient] = useState("");
const [status, setStatus] = useState({ tone: "idle", message: "Ready" });
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const normalizedApiUrl = useMemo(() => normalizeApiUrl(apiUrl), [apiUrl]);
const selectedNote = useMemo(
() => notes.find((note) => noteId(note) === selectedId) || null,
[notes, selectedId],
);
const filteredNotes = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return notes;
return notes.filter((note) =>
`${noteTitle(note)} ${noteContent(note)} ${noteOwner(note)}`.toLowerCase().includes(needle),
);
}, [notes, query]);
const canEditSelected = selectedNote ? ownsNote(selectedNote, currentUser) : true;
useEffect(() => {
writeStored("notes-vault-api-url", apiUrl);
}, [apiUrl]);
useEffect(() => {
writeStored("notes-vault-token", token);
}, [token]);
useEffect(() => {
writeStored("notes-vault-user", currentUser);
}, [currentUser]);
useEffect(() => {
if (token) {
loadNotes();
}
}, [token]);
function setMessage(tone, message) {
setStatus({ tone, message });
}
async function apiRequest(path, options = {}) {
const headers = {
Accept: "application/json",
...(options.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {}),
};
const response = await fetch(`${normalizedApiUrl}${path}`, {
...options,
headers,
});
const text = await response.text();
const payload = text ? parsePayload(text) : null;
if (!response.ok) {
const message = payload?.message || payload?.error || text || `${response.status} ${response.statusText}`;
throw new Error(message);
}
return payload;
}
async function handleAuth(event) {
event.preventDefault();
setBusy(true);
setMessage("idle", authMode === "login" ? "Signing in..." : "Creating account...");
try {
const path = authMode === "login" ? "/auth/login" : "/auth/register";
const body = buildAuthBody(authForm);
const payload = await apiRequest(path, {
method: "POST",
body: JSON.stringify(body),
});
const receivedToken = extractToken(payload);
if (!receivedToken) {
setMessage("ok", "Account request succeeded, but no token was returned. Try signing in.");
return;
}
setToken(receivedToken);
setCurrentUser(extractUser(payload, authForm.username));
setAuthForm((form) => ({ ...form, password: "" }));
setMessage("ok", "Signed in. Notes are loading.");
} catch (error) {
setMessage("error", error.message);
} finally {
setBusy(false);
}
}
async function loadNotes() {
if (!token) return;
setBusy(true);
setMessage("idle", "Loading notes...");
try {
const payload = await apiRequest("/notes");
const nextNotes = Array.isArray(payload) ? payload : payload?.notes || payload?.data || [];
setNotes(nextNotes);
if (nextNotes.length && !nextNotes.some((note) => noteId(note) === selectedId)) {
selectNote(nextNotes[0]);
} else if (!nextNotes.length) {
newDraft();
}
setMessage("ok", `${nextNotes.length} note${nextNotes.length === 1 ? "" : "s"} loaded.`);
} catch (error) {
setMessage("error", error.message);
} finally {
setBusy(false);
}
}
async function loadNote(id) {
if (!id) return;
setBusy(true);
setMessage("idle", "Opening note...");
try {
const payload = await apiRequest(`/notes/${encodeURIComponent(id)}`);
const note = payload?.note || payload?.data || payload;
setNotes((current) => upsertNote(current, note));
selectNote(note);
setMessage("ok", "Note opened.");
} catch (error) {
setMessage("error", error.message);
} finally {
setBusy(false);
}
}
function selectNote(note) {
setSelectedId(noteId(note));
setDraft({ title: noteTitle(note), content: noteContent(note) });
setShareRecipient("");
}
function newDraft() {
setSelectedId("");
setDraft({ title: "", content: "" });
setShareRecipient("");
}
async function saveNote(event) {
event.preventDefault();
if (!draft.title.trim() && !draft.content.trim()) {
setMessage("error", "Add a title or note body before saving.");
return;
}
setBusy(true);
setMessage("idle", selectedId ? "Saving note..." : "Creating note...");
try {
const payload = await apiRequest(selectedId ? `/notes/${encodeURIComponent(selectedId)}` : "/notes", {
method: selectedId ? "PUT" : "POST",
body: JSON.stringify({
title: draft.title.trim() || "Untitled note",
content: draft.content,
}),
});
const savedNote = payload?.note || payload?.data || payload;
setNotes((current) => upsertNote(current, savedNote));
selectNote(savedNote);
setMessage("ok", selectedId ? "Note saved." : "Note created.");
} catch (error) {
setMessage("error", error.message);
} finally {
setBusy(false);
}
}
async function deleteNote() {
if (!selectedId || !confirm("Delete this note?")) return;
setBusy(true);
setMessage("idle", "Deleting note...");
try {
await apiRequest(`/notes/${encodeURIComponent(selectedId)}`, { method: "DELETE" });
const remaining = notes.filter((note) => noteId(note) !== selectedId);
setNotes(remaining);
if (remaining.length) {
selectNote(remaining[0]);
} else {
newDraft();
}
setMessage("ok", "Note deleted.");
} catch (error) {
setMessage("error", error.message);
} finally {
setBusy(false);
}
}
async function shareNote() {
if (!selectedId || !shareRecipient.trim()) return;
setBusy(true);
setMessage("idle", "Sharing note...");
try {
await apiRequest(`/notes/${encodeURIComponent(selectedId)}/share`, {
method: "POST",
body: JSON.stringify({ recipient: shareRecipient.trim(), email: shareRecipient.trim(), username: shareRecipient.trim() }),
});
setShareRecipient("");
setMessage("ok", "Note shared read-only.");
} catch (error) {
setMessage("error", error.message);
} finally {
setBusy(false);
}
}
function logout() {
setToken("");
setCurrentUser("");
setNotes([]);
newDraft();
setMessage("idle", "Signed out.");
}
async function copyToken() {
if (!token) return;
await navigator.clipboard.writeText(token);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}
return (
<main className="app-shell">
<aside className="sidebar" aria-label="Notes vault controls">
<section className="brand-row">
<div>
<p className="eyebrow">Vault</p>
<h1>Notes</h1>
</div>
{token ? (
<button className="icon-button" onClick={logout} title="Sign out" aria-label="Sign out">
<LogOut size={18} />
</button>
) : null}
</section>
<section className="panel settings-panel">
<label htmlFor="api-url">
<Settings size={16} />
API endpoint
</label>
<input
id="api-url"
value={apiUrl}
onChange={(event) => setApiUrl(event.target.value)}
placeholder={DEFAULT_API_URL}
/>
</section>
{!token ? (
<AuthPanel
authMode={authMode}
setAuthMode={setAuthMode}
authForm={authForm}
setAuthForm={setAuthForm}
handleAuth={handleAuth}
busy={busy}
/>
) : (
<>
<section className="user-strip">
<Lock size={16} />
<span>{currentUser || "Authenticated"}</span>
<button className="ghost-icon" onClick={copyToken} title="Copy JWT" aria-label="Copy JWT">
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</section>
<section className="toolbar">
<div className="search">
<Search size={16} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search notes"
aria-label="Search notes"
/>
</div>
<button className="icon-button" onClick={loadNotes} disabled={busy} title="Refresh notes" aria-label="Refresh notes">
<RefreshCw size={18} className={busy ? "spin" : ""} />
</button>
<button className="icon-button accent" onClick={newDraft} title="New note" aria-label="New note">
<Plus size={18} />
</button>
</section>
<nav className="notes-list" aria-label="Notes">
{filteredNotes.length ? (
filteredNotes.map((note) => {
const id = noteId(note);
const active = id === selectedId;
const editable = ownsNote(note, currentUser);
return (
<button
key={id}
className={`note-item ${active ? "active" : ""}`}
onClick={() => loadNote(id)}
>
<strong>{noteTitle(note)}</strong>
<span>{noteContent(note) || "No body text"}</span>
<small>{editable ? "Owned" : "Shared read-only"}</small>
</button>
);
})
) : (
<p className="empty-state">No notes found.</p>
)}
</nav>
</>
)}
</aside>
<section className="workspace" aria-label="Note editor">
<header className="workspace-header">
<div>
<p className="eyebrow">{selectedId ? (canEditSelected ? "Editing" : "Reading") : "New note"}</p>
<h2>{draft.title || "Untitled note"}</h2>
</div>
<StatusBadge status={status} />
</header>
{token ? (
<form className="editor" onSubmit={saveNote}>
<input
className="title-input"
value={draft.title}
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
placeholder="Title"
disabled={!canEditSelected}
/>
<textarea
value={draft.content}
onChange={(event) => setDraft((current) => ({ ...current, content: event.target.value }))}
placeholder="Write the note..."
disabled={!canEditSelected}
/>
<div className="editor-actions">
<button className="primary-button" type="submit" disabled={busy || !canEditSelected}>
<Save size={18} />
Save
</button>
<button className="secondary-button" type="button" onClick={newDraft}>
<Plus size={18} />
New
</button>
<button
className="danger-button"
type="button"
onClick={deleteNote}
disabled={busy || !selectedId || !canEditSelected}
>
<Trash2 size={18} />
Delete
</button>
</div>
{selectedId && canEditSelected ? (
<div className="share-row">
<label htmlFor="share-recipient">
<Send size={16} />
Share read-only
</label>
<input
id="share-recipient"
value={shareRecipient}
onChange={(event) => setShareRecipient(event.target.value)}
placeholder="Recipient username or email"
/>
<button className="secondary-button" type="button" onClick={shareNote} disabled={busy || !shareRecipient.trim()}>
<UserPlus size={18} />
Share
</button>
</div>
) : null}
</form>
) : (
<section className="signed-out">
<KeyRound size={42} />
<h2>Sign in to open the vault</h2>
<p>Point the app at the API, register or log in, then create, edit, delete, and share notes.</p>
</section>
)}
</section>
</main>
);
}
function AuthPanel({ authMode, setAuthMode, authForm, setAuthForm, handleAuth, busy }) {
const isLogin = authMode === "login";
return (
<section className="panel auth-panel">
<div className="segmented" role="tablist" aria-label="Authentication mode">
<button type="button" className={isLogin ? "selected" : ""} onClick={() => setAuthMode("login")}>
<LogIn size={16} />
Login
</button>
<button type="button" className={!isLogin ? "selected" : ""} onClick={() => setAuthMode("register")}>
<UserPlus size={16} />
Register
</button>
</div>
<form onSubmit={handleAuth}>
<label htmlFor="auth-username">Username</label>
<input
id="auth-username"
value={authForm.username}
onChange={(event) => setAuthForm((form) => ({ ...form, username: event.target.value }))}
placeholder="username"
/>
<label htmlFor="auth-password">Password</label>
<input
id="auth-password"
type="password"
value={authForm.password}
onChange={(event) => setAuthForm((form) => ({ ...form, password: event.target.value }))}
placeholder="password"
required
/>
<button className="primary-button full-width" type="submit" disabled={busy}>
{isLogin ? <LogIn size={18} /> : <UserPlus size={18} />}
{isLogin ? "Sign in" : "Create account"}
</button>
</form>
</section>
);
}
function StatusBadge({ status }) {
return <div className={`status-badge ${status.tone}`}>{status.message}</div>;
}
function parsePayload(text) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
function normalizeApiUrl(value) {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "";
return trimmed.replace(/\/+$/, "");
}
function buildAuthBody(form) {
const body = { password: form.password };
if (form.username.trim()) body.username = form.username.trim();
return body;
}
function upsertNote(notes, note) {
const id = noteId(note);
if (!id) return notes;
const exists = notes.some((existing) => noteId(existing) === id);
if (exists) {
return notes.map((existing) => (noteId(existing) === id ? note : existing));
}
return [note, ...notes];
}
function ownsNote(note, currentUser) {
const owner = String(noteOwner(note) || "").toLowerCase();
const user = String(currentUser || "").toLowerCase();
if (!owner || !user) return !note?.sharedWith && !note?.shared;
return owner === user || owner.includes(user) || user.includes(owner);
}
createRoot(document.getElementById("root")).render(<App />);

497
src/styles.css Normal file
View File

@@ -0,0 +1,497 @@
:root {
color-scheme: light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #202326;
background: #f5f2eb;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled,
input:disabled,
textarea:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.app-shell {
display: grid;
grid-template-columns: minmax(300px, 380px) 1fr;
min-height: 100vh;
background:
linear-gradient(90deg, #efe7db 0, #f5f2eb 34%, #eef5f0 100%);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100vh;
padding: 24px;
border-right: 1px solid rgba(40, 43, 46, 0.12);
background: rgba(250, 248, 243, 0.78);
backdrop-filter: blur(16px);
}
.brand-row,
.workspace-header,
.toolbar,
.editor-actions,
.share-row,
.user-strip,
.settings-panel label {
display: flex;
align-items: center;
}
.brand-row,
.workspace-header {
justify-content: space-between;
gap: 16px;
}
.eyebrow {
margin: 0 0 4px;
color: #6c756f;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 3.2rem);
line-height: 0.94;
}
h2 {
max-width: 56rem;
overflow-wrap: anywhere;
font-size: clamp(1.45rem, 2.6vw, 2.75rem);
line-height: 1;
}
.panel {
border: 1px solid rgba(42, 44, 46, 0.13);
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
box-shadow: 0 18px 60px rgba(54, 49, 40, 0.08);
}
.settings-panel,
.auth-panel {
padding: 14px;
}
.settings-panel label,
.share-row label {
gap: 8px;
margin-bottom: 8px;
color: #404844;
font-size: 0.86rem;
font-weight: 760;
}
input,
textarea {
width: 100%;
border: 1px solid rgba(39, 44, 48, 0.16);
border-radius: 7px;
background: rgba(255, 255, 255, 0.9);
color: #202326;
outline: none;
transition:
border-color 140ms ease,
box-shadow 140ms ease,
background 140ms ease;
}
input {
min-height: 42px;
padding: 0 12px;
}
textarea {
min-height: 46vh;
resize: vertical;
padding: 16px;
line-height: 1.55;
}
input:focus,
textarea:focus {
border-color: #297c72;
box-shadow: 0 0 0 4px rgba(41, 124, 114, 0.14);
background: #fff;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 16px;
padding: 4px;
border: 1px solid rgba(40, 43, 46, 0.1);
border-radius: 8px;
background: #ede8df;
}
.segmented button,
.primary-button,
.secondary-button,
.danger-button,
.icon-button,
.ghost-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 7px;
font-weight: 800;
letter-spacing: 0;
}
.segmented button {
min-height: 34px;
color: #56615c;
background: transparent;
}
.segmented button.selected {
color: #162522;
background: #ffffff;
box-shadow: 0 8px 20px rgba(38, 38, 34, 0.1);
}
.auth-panel form {
display: grid;
gap: 9px;
}
.auth-panel label {
color: #4a514d;
font-size: 0.84rem;
font-weight: 760;
}
.primary-button,
.secondary-button,
.danger-button {
min-height: 42px;
padding: 0 14px;
}
.primary-button {
color: #f7fbf8;
background: #1e6f66;
}
.secondary-button {
color: #25302c;
background: #e5ddd2;
}
.danger-button {
color: #fff7f4;
background: #b9473c;
}
.full-width {
width: 100%;
margin-top: 8px;
}
.icon-button,
.ghost-icon {
width: 42px;
height: 42px;
flex: 0 0 42px;
}
.icon-button {
color: #25302c;
background: #e4dfd6;
}
.icon-button.accent {
color: #fff;
background: #1e6f66;
}
.ghost-icon {
width: 32px;
height: 32px;
flex-basis: 32px;
color: #39413d;
background: transparent;
}
.user-strip {
gap: 9px;
min-height: 42px;
padding: 8px 10px;
border: 1px solid rgba(42, 44, 46, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.62);
color: #39413d;
font-weight: 760;
}
.user-strip span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar {
gap: 8px;
}
.search {
position: relative;
flex: 1;
}
.search svg {
position: absolute;
top: 13px;
left: 12px;
color: #78827d;
}
.search input {
padding-left: 38px;
}
.notes-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
min-height: 180px;
overflow-y: auto;
padding-right: 3px;
}
.note-item {
display: grid;
gap: 4px;
width: 100%;
padding: 13px;
border: 1px solid rgba(42, 44, 46, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
color: inherit;
text-align: left;
}
.note-item.active {
border-color: rgba(30, 111, 102, 0.45);
background: #ffffff;
box-shadow: inset 4px 0 0 #1e6f66;
}
.note-item strong,
.note-item span,
.note-item small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-item strong {
font-size: 0.98rem;
}
.note-item span {
color: #616b66;
font-size: 0.88rem;
}
.note-item small {
color: #8a5a32;
font-size: 0.74rem;
font-weight: 850;
text-transform: uppercase;
}
.empty-state {
margin: 30px 4px;
color: #6c756f;
text-align: center;
}
.workspace {
display: flex;
flex-direction: column;
gap: 22px;
min-width: 0;
padding: 34px;
}
.workspace-header {
min-height: 72px;
}
.status-badge {
max-width: 34rem;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.7);
color: #59625e;
font-size: 0.88rem;
font-weight: 760;
overflow-wrap: anywhere;
}
.status-badge.ok {
color: #135b4b;
background: #dcefe8;
}
.status-badge.error {
color: #8f2d28;
background: #f7ded8;
}
.editor {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
}
.title-input {
min-height: 58px;
padding: 0 16px;
font-size: 1.22rem;
font-weight: 850;
}
.editor-actions {
flex-wrap: wrap;
gap: 9px;
}
.share-row {
flex-wrap: wrap;
gap: 10px;
padding-top: 8px;
border-top: 1px solid rgba(42, 44, 46, 0.12);
}
.share-row label {
margin: 0;
}
.share-row input {
width: min(360px, 100%);
flex: 1 1 240px;
}
.signed-out {
display: grid;
place-items: center;
align-content: center;
min-height: 60vh;
color: #4c5550;
text-align: center;
}
.signed-out h2 {
margin-top: 18px;
}
.signed-out p {
max-width: 34rem;
margin: 12px 0 0;
line-height: 1.55;
}
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 840px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
min-height: auto;
border-right: 0;
border-bottom: 1px solid rgba(40, 43, 46, 0.12);
}
.notes-list {
max-height: 320px;
}
.workspace {
padding: 22px;
}
.workspace-header {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 520px) {
.sidebar,
.workspace {
padding: 16px;
}
.toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.search {
flex-basis: 100%;
}
.primary-button,
.secondary-button,
.danger-button {
flex: 1 1 130px;
}
}

15
vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});