Initial app
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
.vite.log
|
||||||
|
*.log
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
12
index.html
Normal file
12
index.html
Normal 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
1506
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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
571
src/main.jsx
Normal 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
497
src/styles.css
Normal 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
15
vite.config.js
Normal 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/, ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user