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