DocDoeAI / src /components /library /NotesView.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
13 kB
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ChevronDown, Copy, Download, ExternalLink, FileText, MessageSquareText, RefreshCcw, Trash2, X } from "lucide-react";
import { deleteNote, listNotes, type SavedNote } from "@/lib/student-activity";
import { useStudentActivityVersion } from "@/hooks/useStudentActivity";
import { buildStudyIntentUrl } from "@/lib/study-intents";
// =============================================================================
// Real saved notes — every entry here was saved by the student (from a
// StudyChat answer, a notes-generation handoff, or the tuition demo). No
// placeholder rows: the empty state points at the actions that create notes.
// Extracted from the standalone /notes route so /library can compose it as a
// tab; the /notes route is now a thin wrapper around this view.
// =============================================================================
const SOURCE_LABEL: Record<SavedNote["source"], string> = {
"study-chat": "StudyChat",
"learn-anything": "Learn Anything",
"tuition-demo": "Tuition demo",
};
export function NotesView() {
const [notes, setNotes] = useState<SavedNote[]>([]);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [subjectFilter, setSubjectFilter] = useState("");
const [topicFilter, setTopicFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState("");
const [openNote, setOpenNote] = useState<SavedNote | null>(null);
const activityVersion = useStudentActivityVersion();
useEffect(() => {
// localStorage read on mount + whenever notes change (this tab or another).
let cancelled = false;
void Promise.resolve().then(() => {
if (!cancelled) setNotes(listNotes());
});
return () => {
cancelled = true;
};
}, [activityVersion]);
function handleCopy(note: SavedNote) {
void navigator.clipboard?.writeText(`${note.title}\n\n${note.content}`).then(() => {
setCopiedId(note.id);
setTimeout(() => setCopiedId(null), 1500);
});
}
function handleDelete(id: string) {
deleteNote(id);
setNotes(listNotes());
if (openNote?.id === id) setOpenNote(null);
}
function handleExport(note: SavedNote) {
const blob = new Blob([`${note.title}\n\n${note.content}`], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${note.title.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "docdoe-note"}.txt`;
anchor.click();
URL.revokeObjectURL(url);
}
const subjects = Array.from(new Set(notes.map((note) => note.subject).filter((value): value is string => Boolean(value)))).sort();
const topics = Array.from(new Set(notes.map((note) => note.topic).filter((value): value is string => Boolean(value)))).sort();
const filteredNotes = notes.filter(
(note) =>
(!subjectFilter || note.subject === subjectFilter) &&
(!topicFilter || note.topic === topicFilter) &&
(!sourceFilter || note.source === sourceFilter),
);
return (
<div className="text-white">
<header className="mb-8">
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Your saved notes</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/52">
Answers you saved from StudyChat and notes generated from your lessons. Stored on this device.
</p>
</header>
{openNote ? (
<article className="mb-6 rounded-2xl border border-white/[0.12] bg-[#0B0B0B] p-5 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[10px] font-bold uppercase tracking-[0.14em] text-white/40">Open note</p>
<h3 className="mt-2 text-xl font-semibold text-white">{openNote.title}</h3>
<p className="mt-1 text-xs text-white/45">
{[openNote.subject, openNote.topic, SOURCE_LABEL[openNote.source]].filter(Boolean).join(" · ")}
</p>
</div>
<button type="button" onClick={() => setOpenNote(null)} aria-label="Close open note" className="rounded-lg p-2 text-white/45 hover:text-white">
<X className="size-4" />
</button>
</div>
<p className="mt-5 whitespace-pre-line text-sm leading-7 text-white/75">{openNote.content}</p>
</article>
) : null}
{notes.length > 0 ? (
<div className="mb-6 grid gap-3 rounded-2xl border border-white/[0.08] bg-[#0B0B0B] p-4 sm:grid-cols-3">
<label className="text-[11px] font-semibold text-white/55">
Subject
<select
aria-label="Filter notes by subject"
value={subjectFilter}
onChange={(event) => setSubjectFilter(event.target.value)}
className="mt-1.5 min-h-10 w-full rounded-lg border border-white/[0.1] bg-black px-3 text-sm text-white"
>
<option value="">All subjects</option>
{subjects.map((subject) => <option key={subject} value={subject}>{subject}</option>)}
</select>
</label>
<label className="text-[11px] font-semibold text-white/55">
Topic
<select
aria-label="Filter notes by topic"
value={topicFilter}
onChange={(event) => setTopicFilter(event.target.value)}
className="mt-1.5 min-h-10 w-full rounded-lg border border-white/[0.1] bg-black px-3 text-sm text-white"
>
<option value="">All topics</option>
{topics.map((topic) => <option key={topic} value={topic}>{topic}</option>)}
</select>
</label>
<label className="text-[11px] font-semibold text-white/55">
Source
<select
aria-label="Filter notes by source"
value={sourceFilter}
onChange={(event) => setSourceFilter(event.target.value)}
className="mt-1.5 min-h-10 w-full rounded-lg border border-white/[0.1] bg-black px-3 text-sm text-white"
>
<option value="">All sources</option>
{Object.entries(SOURCE_LABEL).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
</div>
) : null}
{notes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/[0.12] bg-black/30 p-10 text-center">
<FileText className="mx-auto mb-4 size-8 text-white/40" />
<p className="text-base font-semibold text-white">No notes saved yet</p>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-white/52">
Ask a doubt in Study Chat and press <span className="text-white/80">&quot;Save as note&quot;</span> on the
answer, or use the notes generator from any lesson.
</p>
<Link
href={buildStudyIntentUrl({ intent: "generate_notes", prompt: "Make exam notes on a topic I choose" })}
className="mt-6 inline-flex rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-black"
>
Make your first note
</Link>
</div>
) : (
<div className="dd-stagger space-y-3">
{filteredNotes.map((note) => (
<details key={note.id} className="group rounded-2xl border border-white/[0.08] bg-[#0B0B0B]">
<summary className="flex cursor-pointer list-none items-center gap-3 px-5 py-4 [&::-webkit-details-marker]:hidden">
<FileText className="size-4 shrink-0 text-white/45" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-white">{note.title}</span>
<span className="hidden shrink-0 text-[11px] text-white/40 sm:inline">
{SOURCE_LABEL[note.source]}
{note.provenance === "starter" ? " · starter/sample" : ""}
{` · ${new Date(note.at).toLocaleDateString()}`}
</span>
<ChevronDown className="size-4 shrink-0 text-white/40 transition-transform group-open:rotate-180" />
</summary>
<div className="border-t border-white/[0.06] px-5 py-4">
<p className="whitespace-pre-line text-sm leading-7 text-white/70">{note.content}</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setOpenNote(note)}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg bg-white px-3 text-xs font-semibold text-black"
>
<ExternalLink className="size-3.5" /> Open
</button>
<button
type="button"
onClick={() => handleCopy(note)}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-white/[0.1] bg-white/[0.03] px-3 text-xs font-semibold text-white/70 transition hover:text-white"
>
<Copy className="size-3.5" />
{copiedId === note.id ? "Copied ✓" : "Copy"}
</button>
<button
type="button"
onClick={() => handleExport(note)}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-white/[0.1] bg-white/[0.03] px-3 text-xs font-semibold text-white/70 transition hover:text-white"
>
<Download className="size-3.5" /> Export .txt
</button>
<Link
href={buildStudyIntentUrl({
intent: "generate_notes",
prompt: `Regenerate clearer exam-ready notes for:\n${note.title}\n${note.content.slice(0, 400)}`,
run: true,
})}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-white/[0.1] bg-white/[0.03] px-3 text-xs font-semibold text-white/70 transition hover:text-white"
>
<RefreshCcw className="size-3.5" />
Regenerate
</Link>
<Link
href={buildStudyIntentUrl({
intent: "teach_topic",
prompt: `Use this saved note as context and help me study it:\n${note.title}\n${note.content.slice(0, 400)}`,
run: false,
})}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-white/[0.1] bg-white/[0.03] px-3 text-xs font-semibold text-white/70 transition hover:text-white"
>
<MessageSquareText className="size-3.5" /> Send to StudyChat
</Link>
<Link
href={buildStudyIntentUrl({
intent: "generate_quiz",
prompt: `Make a 5-question practice quiz from this note:\n${note.title}\n${note.content.slice(0, 400)}`,
run: true,
})}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-white/[0.1] bg-white/[0.03] px-3 text-xs font-semibold text-white/70 transition hover:text-white"
>
Turn into quiz
</Link>
<Link
href={buildStudyIntentUrl({
intent: "generate_flashcards",
prompt: `Turn this note into flashcards:\n${note.title}\n${note.content.slice(0, 400)}`,
run: true,
})}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-white/[0.1] bg-white/[0.03] px-3 text-xs font-semibold text-white/70 transition hover:text-white"
>
Turn into flashcards
</Link>
<button
type="button"
onClick={() => handleDelete(note.id)}
className="ml-auto inline-flex min-h-9 items-center gap-1.5 rounded-lg px-3 text-xs font-semibold text-white/40 transition hover:text-white/70"
>
<Trash2 className="size-3.5" />
Delete
</button>
</div>
</div>
</details>
))}
{filteredNotes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/[0.12] p-8 text-center text-sm text-white/52">
No saved notes match these filters.
</div>
) : null}
</div>
)}
</div>
);
}