Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -103,6 +104,7 @@ export default function RootLayout({
<AppStoreProvider>
<AppShell footer={<Footer />}>{children}</AppShell>
<PrivacyNotice />
<Toaster closeButton position="bottom-center" />
</AppStoreProvider>
</ThemeProvider>
</body>
Expand Down
5 changes: 4 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions components/breakout/breakout-groups-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'}
</Button>
</div>
Expand All @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions components/quizzes/quiz-editor-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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),
Expand All @@ -150,22 +172,33 @@ export default function QuizEditorForm({
setPrompt('');
setAnswer('');
}
toast.success('Question removed.');
};

/**
* Cancels question editing and resets the editor fields.
*/
const handleCancelEdit = () => {
setEditingQuestionId(null);
setPrompt('');
setAnswer('');
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 (
Expand Down
17 changes: 15 additions & 2 deletions components/quizzes/quiz-import-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -32,8 +34,12 @@ export default function QuizImportCard() {
const [importError, setImportError] = useState<string | null>(null);
const [importNotice, setImportNotice] = useState<string | null>(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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
}
};

Expand Down
30 changes: 29 additions & 1 deletion components/students/student-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -47,6 +49,13 @@ export default function StudentForm({
const [importError, setImportError] = useState<string | null>(null);
const [importNotice, setImportNotice] = useState<string | null>(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)),
Expand Down Expand Up @@ -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;
Expand All @@ -95,18 +117,24 @@ 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 {
setImportError(null);
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 = '';
}
Expand Down
Loading
Loading