diff --git a/app/globals.css b/app/globals.css index a73758d..83cfc04 100644 --- a/app/globals.css +++ b/app/globals.css @@ -325,3 +325,62 @@ transform-origin: var(--radix-tooltip-content-transform-origin); animation: scaleIn 0.3s ease-in-out; } + +/* Sonner success/error toast colors (Catppuccin) */ +[data-sonner-toaster][data-sonner-theme='light'] + [data-sonner-toast][data-type='error'] { + background: oklch(var(--ctp-latte-red)) !important; + color: oklch(var(--ctp-latte-crust)) !important; + border: 1px solid oklch(var(--ctp-latte-crust)) !important; +} +[data-sonner-toaster][data-sonner-theme='light'] + [data-sonner-toast][data-type='error'] + [data-title], +[data-sonner-toaster][data-sonner-theme='light'] + [data-sonner-toast][data-type='error'] + [data-description] { + color: inherit; +} +[data-sonner-toaster][data-sonner-theme='light'] + [data-sonner-toast][data-type='success'] { + background: oklch(var(--ctp-latte-green)) !important; + color: oklch(var(--ctp-latte-crust)) !important; + border: 1px solid oklch(var(--ctp-latte-crust)) !important; +} +[data-sonner-toaster][data-sonner-theme='light'] + [data-sonner-toast][data-type='success'] + [data-title], +[data-sonner-toaster][data-sonner-theme='light'] + [data-sonner-toast][data-type='success'] + [data-description] { + color: inherit; +} + +[data-sonner-toaster][data-sonner-theme='dark'] + [data-sonner-toast][data-type='error'] { + background: oklch(var(--ctp-mocha-red)) !important; + color: oklch(var(--ctp-mocha-crust)) !important; + border: 1px solid oklch(var(--ctp-mocha-crust)) !important; +} +[data-sonner-toaster][data-sonner-theme='dark'] + [data-sonner-toast][data-type='error'] + [data-title], +[data-sonner-toaster][data-sonner-theme='dark'] + [data-sonner-toast][data-type='error'] + [data-description] { + color: inherit; +} +[data-sonner-toaster][data-sonner-theme='dark'] + [data-sonner-toast][data-type='success'] { + background: oklch(var(--ctp-mocha-green)) !important; + color: oklch(var(--ctp-mocha-crust)) !important; + border: 1px solid oklch(var(--ctp-mocha-crust)) !important; +} +[data-sonner-toaster][data-sonner-theme='dark'] + [data-sonner-toast][data-type='success'] + [data-title], +[data-sonner-toaster][data-sonner-theme='dark'] + [data-sonner-toast][data-type='success'] + [data-description] { + color: inherit; +} diff --git a/app/layout.tsx b/app/layout.tsx index 7e918e2..18537f1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import './globals.css'; import AppShell from '@/components/app-shell'; import Footer from '@/components/footer'; import PrivacyNotice from '@/components/privacy-notice'; +import { Toaster } from '@/components/ui/sonner'; import { AppStoreProvider } from '@/context/app-store'; import { ThemeProvider } from '@/context/theme-provider'; @@ -103,6 +104,7 @@ export default function RootLayout({ }>{children} + diff --git a/bun.lock b/bun.lock index b455319..dfe1b22 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "teacherbuddy", @@ -14,6 +14,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "shadcn": "^3.8.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", }, @@ -1422,6 +1423,8 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/components/breakout/breakout-groups-card.tsx b/components/breakout/breakout-groups-card.tsx index cb439b8..1eff20d 100644 --- a/components/breakout/breakout-groups-card.tsx +++ b/components/breakout/breakout-groups-card.tsx @@ -7,6 +7,7 @@ import { formatStudentName } from '@/lib/students'; import { useEffect, useMemo, useRef, useState } from 'react'; import { CheckIcon, CopyIcon } from 'lucide-react'; +import { toast } from 'sonner'; import GeneratorCardSkeleton from '@/components/loading/generator-card-skeleton'; import { Badge } from '@/components/ui/badge'; @@ -173,7 +174,10 @@ export default function BreakoutGroupsCard() { variant="secondary" className="h-9 font-semibold text-base sm:min-w-32" disabled={!groups.length} - onClick={() => copyAll(groupSummary)}> + onClick={async () => { + const ok = await copyAll(groupSummary); + if (!ok) toast.error('Failed to copy to clipboard.'); + }}> {isAllCopied ? 'Copied!' : 'Copy Groups'} @@ -198,13 +202,17 @@ export default function BreakoutGroupsCard() { ? `Copied group ${index + 1}` : `Copy group ${index + 1}` } - onClick={() => { + onClick={async () => { const names = group .map((student: Student) => formatStudentName(student.name), ) .join(', '); - copyGroup(names); + const ok = await copyGroup(names); + if (!ok) { + toast.error('Failed to copy to clipboard.'); + return; + } setCopiedGroupIndex(index); if (copyGroupTimeoutRef.current !== null) { clearTimeout(copyGroupTimeoutRef.current); diff --git a/components/quizzes/quiz-editor-form.tsx b/components/quizzes/quiz-editor-form.tsx index 0aa9171..2101674 100644 --- a/components/quizzes/quiz-editor-form.tsx +++ b/components/quizzes/quiz-editor-form.tsx @@ -5,6 +5,7 @@ import type { Question, Quiz } from '@/lib/models'; import { useMemo, useState } from 'react'; import { PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'; +import { toast } from 'sonner'; import QuizSelector from '@/components/quizzes/quiz-selector'; import { @@ -79,6 +80,9 @@ export default function QuizEditorForm({ [questions, editingQuestionId], ); + /** + * Validates quiz metadata, saves changes, and confirms with a toast. + */ const handleSaveQuiz = () => { const trimmed = title.trim(); if (!trimmed) { @@ -92,12 +96,17 @@ export default function QuizEditorForm({ if (quizId) { actions.updateQuiz(quizId, trimmed, questions); + toast.success('Quiz updated.'); } else { actions.createQuiz(trimmed, questions); + toast.success('Quiz created.'); } setQuizError(null); }; + /** + * Adds a new question or updates the current edit and resets the inputs. + */ const handleAddOrUpdateQuestion = () => { const trimmedPrompt = prompt.trim(); const trimmedAnswer = answer.trim(); @@ -130,8 +139,16 @@ export default function QuizEditorForm({ setAnswer(''); setEditingQuestionId(null); setQuestionError(null); + toast.success( + editingQuestionId ? 'Question updated.' : 'Question added.', + ); }; + /** + * Loads the selected question into the edit form. + * + * @param questionId - Identifier of the question to edit. + */ const handleEditQuestion = (questionId: string) => { const question = questions.find((item) => item.id === questionId); if (!question) return; @@ -141,6 +158,11 @@ export default function QuizEditorForm({ setQuestionError(null); }; + /** + * Removes a question from the draft and clears the editor if needed. + * + * @param questionId - Identifier of the question to remove. + */ const handleRemoveQuestion = (questionId: string) => { setQuestions((prev) => prev.filter((question) => question.id !== questionId), @@ -150,8 +172,12 @@ export default function QuizEditorForm({ setPrompt(''); setAnswer(''); } + toast.success('Question removed.'); }; + /** + * Cancels question editing and resets the editor fields. + */ const handleCancelEdit = () => { setEditingQuestionId(null); setPrompt(''); @@ -159,13 +185,20 @@ export default function QuizEditorForm({ setQuestionError(null); }; + /** + * Clears the active quiz selection to start a new quiz. + */ const handleNewQuiz = () => { actions.selectQuizForEditor(null); }; + /** + * Deletes the current quiz and confirms the action with a toast. + */ const handleDeleteQuiz = () => { if (!quizId) return; actions.deleteQuiz(quizId); + toast.success('Quiz deleted.'); }; return ( diff --git a/components/quizzes/quiz-import-card.tsx b/components/quizzes/quiz-import-card.tsx index e83843c..8359cbd 100644 --- a/components/quizzes/quiz-import-card.tsx +++ b/components/quizzes/quiz-import-card.tsx @@ -4,6 +4,8 @@ import type { Question } from '@/lib/models'; import { useState } from 'react'; +import { toast } from 'sonner'; + import { Button } from '@/components/ui/button'; import { Card, @@ -32,8 +34,12 @@ export default function QuizImportCard() { const [importError, setImportError] = useState(null); const [importNotice, setImportNotice] = useState(null); + /** + * Parses JSON input, creates quizzes, and summarizes the import result. + */ const handleImportQuiz = () => { if (!importPayload.trim()) { + toast.error('Paste a JSON quiz payload to import.'); setImportError('Paste a JSON quiz payload to import.'); setImportNotice(null); return; @@ -77,8 +83,10 @@ export default function QuizImportCard() { .filter(Boolean) as Array<{ title: string; questions: Question[] }>; if (!drafts.length) { - setImportError('No valid quiz data found in that JSON.'); + const message = 'No valid quiz data found in that JSON.'; + setImportError(message); setImportNotice(null); + toast.error(message); return; } @@ -88,11 +96,16 @@ export default function QuizImportCard() { setImportNotice( `Imported ${drafts.length} quiz${drafts.length === 1 ? '' : 'zes'}.`, ); + toast.success( + `Imported ${drafts.length} quiz${drafts.length === 1 ? '' : 'zes'}.`, + ); setImportError(null); setImportPayload(''); } catch { - setImportError('Invalid JSON. Please check the format and try again.'); + const message = 'Invalid JSON. Please check the format and try again.'; + setImportError(message); setImportNotice(null); + toast.error(message); } }; diff --git a/components/students/student-form.tsx b/components/students/student-form.tsx index 0f51a01..25f3ab3 100644 --- a/components/students/student-form.tsx +++ b/components/students/student-form.tsx @@ -4,6 +4,8 @@ import { normalizeStudentName, studentNameKey } from '@/lib/students'; import { useState } from 'react'; +import { toast } from 'sonner'; + import { Button } from '@/components/ui/button'; import { Card, @@ -47,6 +49,13 @@ export default function StudentForm({ const [importError, setImportError] = useState(null); const [importNotice, setImportNotice] = useState(null); + /** + * Parses a raw comma-separated list, adds unique students, and returns a count. + * Uses persisted students to de-dupe against existing roster entries. + * + * @param raw - Comma-separated student names to add. + * @returns The number of new students added to state. + */ const addNames = (raw: string) => { const existingKeys = new Set( state.persisted.students.map((student) => studentNameKey(student.name)), @@ -75,18 +84,31 @@ export default function StudentForm({ return uniqueNames.length; }; + /** + * Handles manual student entry submission and triggers a toast on success. + * + * @param event - Form submit event from the student name form. + */ const handleSubmit = (event: FormSubmitEvent) => { event.preventDefault(); const addedCount = addNames(name); if (!addedCount) { + toast.error('Enter at least one new student name.'); setError('Enter at least one new student name.'); return; } setName(''); setError(null); setImportNotice(null); + toast.success(`Added ${addedCount} student${addedCount === 1 ? '' : 's'}.`); }; + /** + * Reads a .txt upload, imports students, and confirms the result with a toast. + * + * @param event - File input change event containing the uploaded file. + * @returns A promise that resolves after the file is processed. + */ const handleFileImport = async (event: InputChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; @@ -95,6 +117,7 @@ export default function StudentForm({ const text = await file.text(); const addedCount = addNames(text); if (!addedCount) { + toast.error('No new students were found in that file.'); setImportError('No new students were found in that file.'); setImportNotice(null); } else { @@ -102,11 +125,16 @@ export default function StudentForm({ setImportNotice( `Imported ${addedCount} student${addedCount === 1 ? '' : 's'}.`, ); + toast.success( + `Imported ${addedCount} student${addedCount === 1 ? '' : 's'}.`, + ); } } catch (error) { console.error('Failed to import students', error); - setImportError('Could not read the file. Please try again.'); + const message = 'Could not read the file. Please try again.'; + setImportError(message); setImportNotice(null); + toast.error(message); } finally { event.target.value = ''; } diff --git a/components/students/student-table.tsx b/components/students/student-table.tsx index 6fcea76..81dadfb 100644 --- a/components/students/student-table.tsx +++ b/components/students/student-table.tsx @@ -9,6 +9,8 @@ import { cn } from '@/lib/utils'; import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; + import { AlertDialog, AlertDialogAction, @@ -80,6 +82,11 @@ export default function StudentTable({ ) ?? null) : null; + /** + * Opens the edit dialog for a student and seeds the form inputs. + * + * @param studentId - Identifier of the student to edit. + */ const handleOpenEdit = (studentId: string) => { const student = state.persisted.students.find( (item) => item.id === studentId, @@ -90,12 +97,18 @@ export default function StudentTable({ setEditError(null); }; + /** + * Clears edit state and closes the edit dialog. + */ const handleCloseEdit = () => { setEditingStudentId(null); setEditName(''); setEditError(null); }; + /** + * Validates the edited name, updates the student, and confirms success. + */ const handleSaveEdit = () => { const normalized = normalizeStudentName(editName); if (!normalized) { @@ -114,10 +127,52 @@ export default function StudentTable({ } if (editingStudentId) { actions.updateStudent(editingStudentId, normalized); + toast.success('Student updated.'); } handleCloseEdit(); }; + /** + * Toggles a student's active/excluded status and surfaces a toast update. + * + * @param studentId - Identifier of the student to toggle. + */ + const handleToggleExcluded = (studentId: string) => { + const student = state.persisted.students.find( + (item) => item.id === studentId, + ); + actions.toggleStudentExcluded(studentId); + if (student) { + const nextStatus = student.status === 'active' ? 'excluded' : 'active'; + toast.success( + `${formatStudentName(student.name)} marked as ${nextStatus}.`, + ); + } + }; + + /** + * Deletes a student and shows a confirmation toast. + * + * @param studentId - Identifier of the student to delete. + */ + const handleDeleteStudent = (studentId: string) => { + const student = state.persisted.students.find( + (item) => item.id === studentId, + ); + actions.deleteStudent(studentId); + if (student) { + toast.success(`Removed ${formatStudentName(student.name)}.`); + } + }; + + /** + * Clears the roster and confirms the destructive action. + */ + const handleClearStudents = () => { + actions.clearStudents(); + toast.success('All students deleted.'); + }; + if (!state.ui.isHydrated) { return <>{skeleton}; } @@ -149,12 +204,12 @@ export default function StudentTable({ This will permanently remove every student from the roster. - - Cancel - - Delete All - - + + Cancel + + Delete All + + ) : null} @@ -212,7 +267,7 @@ export default function StudentTable({ - actions.toggleStudentExcluded(student.id) + handleToggleExcluded(student.id) } aria-label={`Mark ${student.name} as absent`} className="touch-hitbox cursor-pointer border-accent/25" @@ -250,7 +305,7 @@ export default function StudentTable({ Cancel - actions.deleteStudent(student.id) + handleDeleteStudent(student.id) }> Delete diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000..9280ee5 --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, type ToasterProps } from "sonner" +import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: ( + + ), + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/package.json b/package.json index c751ca6..37f0989 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "shadcn": "^3.8.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0" },