first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
283
frontend/src/components/StickyNotesPanel.jsx
Normal file
283
frontend/src/components/StickyNotesPanel.jsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import {
|
||||
MoreHorizontal,
|
||||
Pin,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createStickyNote,
|
||||
deleteStickyNote,
|
||||
listStickyNotes,
|
||||
} from "../api";
|
||||
import bluePin from "../data/blue.png";
|
||||
import greenPin from "../data/green.png";
|
||||
import pinkPin from "../data/pink.png";
|
||||
import yellowPin from "../data/yellow.png";
|
||||
|
||||
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
|
||||
const PIN_IMAGES = {
|
||||
yellow: yellowPin,
|
||||
green: greenPin,
|
||||
blue: bluePin,
|
||||
pink: pinkPin,
|
||||
};
|
||||
|
||||
function formatNoteTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
|
||||
return (
|
||||
<article className={`sticky-note sticky-note-${note.color}`}>
|
||||
<span className="sticky-note-pin" aria-hidden="true">
|
||||
<img src={PIN_IMAGES[note.color] || yellowPin} alt="" />
|
||||
</span>
|
||||
<header className="sticky-note-header">
|
||||
<h3>{note.title}</h3>
|
||||
<div className="sticky-note-menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="sticky-note-menu-btn"
|
||||
aria-label="Note options"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMenuToggle(note.id);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="sticky-note-menu">
|
||||
<button type="button" onClick={() => onDelete(note.id)}>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{note.body && <p>{note.body}</p>}
|
||||
<footer className="sticky-note-footer">
|
||||
<div className="sticky-note-tags">
|
||||
{(note.tags.length ? note.tags : ["General"]).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StickyNotesPanel() {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [menuOpenId, setMenuOpenId] = useState(null);
|
||||
const [draft, setDraft] = useState({
|
||||
title: "",
|
||||
body: "",
|
||||
tags: "",
|
||||
color: "yellow",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const items = await listStickyNotes(token);
|
||||
setNotes(items);
|
||||
} catch (loadError) {
|
||||
setError(loadError.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
function closeMenu() {
|
||||
setMenuOpenId(null);
|
||||
}
|
||||
window.addEventListener("click", closeMenu);
|
||||
return () => window.removeEventListener("click", closeMenu);
|
||||
}, []);
|
||||
|
||||
async function handleCreate(event) {
|
||||
event.preventDefault();
|
||||
if (!draft.title.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createStickyNote(
|
||||
{
|
||||
title: draft.title.trim(),
|
||||
body: draft.body.trim(),
|
||||
tags: draft.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
color: draft.color,
|
||||
},
|
||||
token,
|
||||
);
|
||||
setNotes((current) => [created, ...current]);
|
||||
setDraft({
|
||||
title: "",
|
||||
body: "",
|
||||
tags: "",
|
||||
color: NOTE_COLORS[(notes.length + 1) % NOTE_COLORS.length],
|
||||
});
|
||||
setAdding(false);
|
||||
} catch (createError) {
|
||||
setError(createError.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(noteId) {
|
||||
setMenuOpenId(null);
|
||||
setError("");
|
||||
try {
|
||||
await deleteStickyNote(noteId, token);
|
||||
setNotes((current) => current.filter((note) => note.id !== noteId));
|
||||
} catch (deleteError) {
|
||||
setError(deleteError.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBodyKeyDown(event) {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
const textarea = event.currentTarget;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const insertion = "\n• ";
|
||||
const nextValue =
|
||||
value.slice(0, selectionStart) + insertion + value.slice(selectionEnd);
|
||||
setDraft((current) => ({ ...current, body: nextValue }));
|
||||
window.requestAnimationFrame(() => {
|
||||
textarea.selectionStart = selectionStart + insertion.length;
|
||||
textarea.selectionEnd = selectionStart + insertion.length;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="mailbox-notes-pane" aria-label="Sticky notes">
|
||||
<header className="sticky-notes-header">
|
||||
<div className="sticky-notes-title">
|
||||
<Pin size={15} />
|
||||
<h2>Sticky Notes</h2>
|
||||
</div>
|
||||
<div className="sticky-notes-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add sticky note"
|
||||
onClick={() => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
|
||||
}));
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="sticky-notes-body">
|
||||
{loading && <div className="sticky-notes-state">Loading notes…</div>}
|
||||
{error && !loading && (
|
||||
<div className="sticky-notes-state error" role="alert">{error}</div>
|
||||
)}
|
||||
{!loading && notes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
menuOpen={menuOpenId === note.id}
|
||||
onMenuToggle={(id) => setMenuOpenId((current) => (current === id ? null : id))}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{adding ? (
|
||||
<form className="sticky-note-form" onSubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
placeholder="Note title"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
value={draft.body}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, body: event.target.value }))}
|
||||
onKeyDown={handleBodyKeyDown}
|
||||
placeholder="Jot down a quick note…"
|
||||
rows={3}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.tags}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, tags: event.target.value }))}
|
||||
placeholder="Tags (comma separated)"
|
||||
/>
|
||||
<div className="sticky-note-colors">
|
||||
{NOTE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`sticky-note-color${draft.color === color ? " active" : ""}`}
|
||||
data-color={color}
|
||||
aria-label={`${color} note`}
|
||||
onClick={() => setDraft((current) => ({ ...current, color }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="sticky-note-form-actions">
|
||||
<button type="button" onClick={() => setAdding(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={saving || !draft.title.trim()}>
|
||||
{saving ? "Saving…" : "Save note"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="sticky-note-add"
|
||||
onClick={() => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
|
||||
}));
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>Add sticky note</span>
|
||||
<small>Jot down a quick note…</small>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue