319 lines
10 KiB
JavaScript
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>
|
|
);
|
|
}
|