agentic.email/frontend/src/components/StickyNotesPanel.jsx
2026-10-06 18:27:06 +05:30

319 lines
10 KiB
JavaScript

import {
ChevronDown,
MoreHorizontal,
Pin,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo, 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";
import { senderDisplayName } from "../utils/senderAvatar";
import { formatAbsoluteDateTime } from "../utils/datetime";
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
const PIN_IMAGES = {
yellow: yellowPin,
green: greenPin,
blue: bluePin,
pink: pinkPin,
};
function noteMatchesContext(note, contextEmail) {
const needle = senderDisplayName(contextEmail).toLowerCase();
if (!needle) return false;
return (
note.title.toLowerCase().includes(needle)
|| note.body.toLowerCase().includes(needle)
|| note.tags.some((tag) => tag.toLowerCase().includes(needle))
);
}
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}>{formatAbsoluteDateTime(note.updated_at)}</time>
</footer>
</article>
);
}
export default function StickyNotesPanel({ compact = false, contextEmail = null }) {
const token = sessionStorage.getItem("agmail_access_token");
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [expanded, setExpanded] = useState(false);
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 contextualNotes = useMemo(() => {
if (!contextEmail) return [];
return notes.filter((note) => noteMatchesContext(note, contextEmail));
}, [contextEmail, notes]);
const visibleNotes = useMemo(() => {
if (contextEmail && contextualNotes.length) return contextualNotes;
return notes;
}, [contextEmail, contextualNotes, notes]);
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);
setExpanded(true);
} 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;
});
}
const contextLabel = contextEmail ? senderDisplayName(contextEmail) : null;
return (
<aside className={`mailbox-notes-pane${compact ? " compact" : ""}${expanded ? " expanded" : ""}${notes.length === 0 ? " empty" : ""}`} aria-label="Sticky notes">
<button
type="button"
className="sticky-notes-toggle"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
>
<Pin size={14} />
<span>
Notes ({notes.length})
{contextLabel && contextualNotes.length > 0 && (
<small> · {contextualNotes.length} about {contextLabel}</small>
)}
</span>
<ChevronDown size={15} className={expanded ? "open" : ""} />
</button>
{expanded && (
<div className="sticky-notes-body">
{contextLabel && (
<p className="sticky-notes-context">
{contextualNotes.length
? `Notes about ${contextLabel}`
: `No notes linked to ${contextLabel} yet.`}
</p>
)}
<div className="sticky-notes-toolbar">
<button
type="button"
aria-label="Add sticky note"
onClick={() => {
setDraft((current) => ({
...current,
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
tags: contextLabel && !current.tags ? contextLabel : current.tags,
}));
setAdding(true);
}}
>
<Plus size={16} />
</button>
</div>
{loading && <div className="sticky-notes-state">Loading notes…</div>}
{error && !loading && (
<div className="sticky-notes-state error" role="alert">{error}</div>
)}
{!loading && visibleNotes.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],
tags: contextLabel || current.tags,
}));
setAdding(true);
}}
>
<Plus size={18} />
<span>Add note</span>
</button>
)}
</div>
)}
</aside>
);
}