From 3887cbfc232a96c8417975a2737004ee319d0a57 Mon Sep 17 00:00:00 2001 From: JettNguyen Date: Tue, 31 Mar 2026 12:08:47 -0400 Subject: [PATCH 01/18] fix login/signup flow, add ability to delete stacks/classes, and improve user flow for adding stacks and classes --- client/public/index.html | 2 +- client/src/pages/ClassView.css | 90 +++++++++ client/src/pages/ClassView.js | 196 ++++++++++++++++--- client/src/pages/Home.js | 174 ++++++++-------- client/src/pages/LoginSignup.js | 38 ++-- client/src/pages/NewClass.css | 87 +------- client/src/pages/NewClass.js | 70 ++++--- client/src/pages/NewStack.css | 45 +++++ client/src/pages/NewStack.js | 43 +++- client/src/pages/StackView.js | 48 ++++- client/src/styles/Modal.css | 66 +++++-- server/src/controllers/auth/login.ts | 6 +- server/src/controllers/auth/register.ts | 20 +- server/src/controllers/class/add-stack.ts | 47 +++++ server/src/controllers/class/create-class.ts | 38 ++++ server/src/controllers/class/delete-class.ts | 33 ++++ server/src/controllers/stack/delete-stack.ts | 35 ++++ server/src/models/Account.ts | 4 +- server/src/routes/class.ts | 10 +- server/src/routes/stack.ts | 7 +- server/src/utils/joi.ts | 4 +- 21 files changed, 781 insertions(+), 282 deletions(-) create mode 100644 server/src/controllers/class/add-stack.ts create mode 100644 server/src/controllers/class/create-class.ts create mode 100644 server/src/controllers/class/delete-class.ts create mode 100644 server/src/controllers/stack/delete-stack.ts diff --git a/client/public/index.html b/client/public/index.html index 474576c..ae062b8 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -5,7 +5,7 @@ - + diff --git a/client/src/pages/ClassView.css b/client/src/pages/ClassView.css index eb2f449..2ce79f5 100644 --- a/client/src/pages/ClassView.css +++ b/client/src/pages/ClassView.css @@ -176,6 +176,96 @@ } } +.add-stack-select-wrapper { + position: relative; + margin-bottom: 15px; +} + +.add-stack-select-wrapper::after { + content: '▾'; + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + pointer-events: none; + font-size: 14px; +} + +.add-stack-select { + width: 100%; + padding: 13px 40px 13px 15px; + border-radius: 10px; + border: 2px solid var(--border-muted); + background-color: var(--bg-card); + color: var(--text-primary); + font-size: 16px; + font-weight: 600; + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} + +.add-stack-select:focus { + outline: none; + border-color: var(--color-primary); +} + +.add-stack-select option { + background-color: var(--bg-card); + color: var(--text-primary); +} + +.add-stack-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.add-stack-actions:has(:only-child) { + grid-template-columns: 1fr; +} + +.add-stack-secondary, +.add-stack-primary { + height: 45px; + border-radius: 10px; + border: none; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.add-stack-secondary { + background-color: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--bg-tertiary); +} + +.add-stack-primary { + background-color: var(--color-primary); + color: var(--text-primary); +} + +.add-stack-feedback { + margin: 10px 0 0; + font-size: 13px; + font-weight: 600; + color: var(--color-primary); + text-align: center; +} + +@media (hover: hover) and (pointer: fine) { + .add-stack-secondary:hover { + background-color: var(--bg-tertiary); + } + + .add-stack-primary:hover { + background-color: var(--color-primary-dark); + } +} + @media (hover: hover) and (pointer: fine) { .class-view-add-tile:hover { transform: translateY(-5px); diff --git a/client/src/pages/ClassView.js b/client/src/pages/ClassView.js index e1e4baf..4ade42a 100644 --- a/client/src/pages/ClassView.js +++ b/client/src/pages/ClassView.js @@ -15,7 +15,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import Breadcrumbs from '../components/Breadcrumbs'; import Modal from '../components/Modal'; -import { apiRequest } from '../utils/api'; +import { apiRequest, getAuthToken } from '../utils/api'; import './Home.css'; import './StackView.css'; import './ClassView.css'; @@ -32,6 +32,17 @@ const ClassView = () => { const isLoadingVisible = useDelayedSpinner(isLoading, 1000); const [isLinkCopied, setIsLinkCopied] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isAddStackModalOpen, setIsAddStackModalOpen] = useState(false); + const [userStacks, setUserStacks] = useState([]); + const [selectedAddStackId, setSelectedAddStackId] = useState(''); + const [addStackFeedback, setAddStackFeedback] = useState(''); + const [isAddingStack, setIsAddingStack] = useState(false); + + const handleUnavailableAction = (action) => { + setActionMessage(`${action} is currently unavailable.`); + setTimeout(() => setActionMessage(''), 2000); + }; useEffect(() => { let isMounted = true; @@ -43,9 +54,7 @@ const ClassView = () => { try { const response = await apiRequest(`/class/view?class=${id}`); - if (!isMounted) { - return; - } + if (!isMounted) return; setClassItem({ id, name: response?.name || 'Class' }); setStacks( @@ -79,9 +88,7 @@ const ClassView = () => { }, [id]); useEffect(() => { - if (!isLinkCopied) { - return undefined; - } + if (!isLinkCopied) return undefined; const timer = setTimeout(() => { setIsLinkCopied(false); @@ -110,23 +117,83 @@ const ClassView = () => { setIsLinkCopied(true); }; - const handleAddStack = () => { - if (!classItem) { - return; + const handleAddStack = async () => { + if (!classItem) return; + setIsAddStackModalOpen(true); + setAddStackFeedback(''); + setIsAddingStack(false); + setSelectedAddStackId(''); + try { + const res = await apiRequest('/account/user'); + const classStackIds = new Set(stacks.map(s => s.id)); + setUserStacks((res.stacks || []).filter(s => !classStackIds.has(s._id))); + } catch { + setUserStacks([]); + } + }; + + const handleAddExistingStack = async (stackId) => { + if (!classItem || !stackId) return; + setIsAddingStack(true); + setAddStackFeedback(''); + try { + await apiRequest(`/class/add-stack`, { + method: 'POST', + body: JSON.stringify({ classId: classItem.id, stackId }), + }); + setAddStackFeedback('Stack added!'); + setTimeout(() => { + setIsAddStackModalOpen(false); + window.location.reload(); + }, 800); + } catch (err) { + setAddStackFeedback(err.message || 'Failed to add stack.'); + } finally { + setIsAddingStack(false); } + }; - navigate('/stack/new', { - state: { - selectedClassId: classItem.id, - }, - }); + const handleCreateNewStack = () => { + setIsAddStackModalOpen(false); + navigate('/stack/new', { state: { selectedClassId: classItem.id } }); }; - const handleUnavailableAction = (label) => { - setActionMessage(`${label} will be available once class action routes are added.`); - setTimeout(() => { - setActionMessage(''); - }, 2000); + const handleDeleteClass = () => { + if (!classItem) return; + setIsDeleteModalOpen(true); + }; + + const confirmDeleteClass = async () => { + if (!classItem) return; + setIsDeleteModalOpen(false); + setActionMessage('Deleting class...'); + + const token = getAuthToken(); + if (!token) { + setActionMessage('You must be signed in to delete a class.'); + return; + } + + if (!classItem.id) { + setActionMessage('Missing class identifier.'); + return; + } + + try { + await apiRequest('/class/delete', { + method: 'POST', + body: JSON.stringify({ classId: classItem.id }), + }); + setActionMessage('Class deleted.'); + setTimeout(() => navigate('/home'), 1000); + } catch (err) { + const msg = err?.payload?.message || err?.message || 'Failed to delete class.'; + if (err?.status === 403) { + setActionMessage('You are not permitted to delete this class.'); + } else { + setActionMessage(msg); + } + } }; const isOwner = role === 'owner'; @@ -168,6 +235,86 @@ const ClassView = () => { return (
+ setIsDeleteModalOpen(false)} + title="Delete Class" + > +

Are you sure you want to delete this class? This action cannot be undone.

+
+ + +
+
+ + setIsAddStackModalOpen(false)} + title="Add Stack to Class" + > + {userStacks.length > 0 ? ( + <> +
+ +
+
+ + {selectedAddStackId && ( + + )} +
+ + ) : ( +
+ +
+ )} + {addStackFeedback &&

{addStackFeedback}

} +
+ {
setIsSettingsOpen(false)} title="Class Settings"> - {/* Membership Section */}

Membership

- {/* Edit Section */} {canEdit && (

Editing

@@ -305,7 +450,6 @@ const ClassView = () => {
)} - {/* Owner Section */} {isOwner && (

Administration

@@ -338,7 +482,6 @@ const ClassView = () => {
)} - {/* Danger Zone */} {isOwner && (

Danger Zone

@@ -346,8 +489,8 @@ const ClassView = () => { type="button" className="modal-option-button" onClick={() => { - handleUnavailableAction('Delete class'); setIsSettingsOpen(false); + handleDeleteClass(); }} >
@@ -358,7 +501,6 @@ const ClassView = () => {
)} - {/* Members List */} {isOwner && users.length > 0 && (

Class Members ({users.length})

@@ -377,4 +519,4 @@ const ClassView = () => { ); }; -export default ClassView; \ No newline at end of file +export default ClassView; diff --git a/client/src/pages/Home.js b/client/src/pages/Home.js index 43d2973..38b7dd8 100644 --- a/client/src/pages/Home.js +++ b/client/src/pages/Home.js @@ -8,47 +8,43 @@ import { apiRequest, clearAuthToken, getAuthToken } from '../utils/api'; import './Home.css'; const getCollapsedCardCount = () => { - if (typeof window === 'undefined') { - return 3; - } - - if (window.innerWidth >= 1250) { - return 7; - } - - if (window.innerWidth >= 650) { - return 5; - } - + if (typeof window === 'undefined') return 3; + if (window.innerWidth >= 1250) return 7; + if (window.innerWidth >= 650) return 5; return 3; }; +const getRowSize = () => { + if (typeof window === 'undefined') return 2; + if (window.innerWidth >= 1250) return 4; + if (window.innerWidth >= 650) return 3; + return 2; +}; + const Home = () => { const navigate = useNavigate(); - const [showMoreStacks, setShowMoreStacks] = useState(false); - const [showMoreClasses, setShowMoreClasses] = useState(false); const [classes, setClasses] = useState([]); const [stacks, setStacks] = useState([]); const [isLoading, setIsLoading] = useState(true); const [collapsedCardCount, setCollapsedCardCount] = useState(getCollapsedCardCount); + const [rowSize, setRowSize] = useState(getRowSize); + const [stacksVisibleCount, setStacksVisibleCount] = useState(getCollapsedCardCount); + const [classesVisibleCount, setClassesVisibleCount] = useState(getCollapsedCardCount); const isLoadingVisible = useDelayedSpinner(isLoading, 1000); useEffect(() => { - const updateCollapsedCardCount = () => { + const handleResize = () => { setCollapsedCardCount(getCollapsedCardCount()); + setRowSize(getRowSize()); }; - - window.addEventListener('resize', updateCollapsedCardCount); - - return () => { - window.removeEventListener('resize', updateCollapsedCardCount); - }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); }, []); - const shouldShowMoreStacks = stacks.length > collapsedCardCount; - const shouldShowMoreClasses = classes.length > collapsedCardCount; - const visibleStacks = shouldShowMoreStacks && !showMoreStacks ? stacks.slice(0, collapsedCardCount) : stacks; - const visibleClasses = shouldShowMoreClasses && !showMoreClasses ? classes.slice(0, collapsedCardCount) : classes; + useEffect(() => { + setStacksVisibleCount(collapsedCardCount); + setClassesVisibleCount(collapsedCardCount); + }, [collapsedCardCount]); useEffect(() => { if (!getAuthToken()) { @@ -59,15 +55,11 @@ const Home = () => { let isMounted = true; const loadData = async () => { - if (isMounted) { - setIsLoading(true); - } + if (isMounted) setIsLoading(true); try { const response = await apiRequest('/account/user'); - if (!isMounted) { - return; - } + if (!isMounted) return; const apiClasses = Array.isArray(response?.classes) ? response.classes : []; const apiStacks = Array.isArray(response?.stacks) ? response.stacks : []; @@ -90,22 +82,14 @@ const Home = () => { } catch (error) { if (error?.status === 401 || error?.status === 400) { clearAuthToken(); - if (isMounted) { - navigate('/', { replace: true }); - } + if (isMounted) navigate('/', { replace: true }); return; } - - if (!isMounted) { - return; - } - + if (!isMounted) return; setStacks([]); setClasses([]); } finally { - if (isMounted) { - setIsLoading(false); - } + if (isMounted) setIsLoading(false); } }; @@ -116,6 +100,18 @@ const Home = () => { }; }, [navigate]); + const visibleStacks = stacks.slice(0, stacksVisibleCount); + const visibleClasses = classes.slice(0, classesVisibleCount); + const hasMoreStacks = stacks.length > stacksVisibleCount; + const hasMoreClasses = classes.length > classesVisibleCount; + const canCollapseStacks = stacksVisibleCount > collapsedCardCount; + const canCollapseClasses = classesVisibleCount > collapsedCardCount; + + const showMoreStacks = () => setStacksVisibleCount((prev) => prev + rowSize * 2); + const showMoreClasses = () => setClassesVisibleCount((prev) => prev + rowSize * 2); + const collapseStacks = () => setStacksVisibleCount(collapsedCardCount); + const collapseClasses = () => setClassesVisibleCount(collapsedCardCount); + return (
{isLoading ? ( @@ -140,30 +136,33 @@ const Home = () => {

No stacks yet. Add your first!

) : ( -
- {visibleStacks.map((stack) => ( -
navigate(`/stack/${stack.id}`)} - > -
-
-
-
- {stack.name} - {stack.className && {stack.className}} +
+ {visibleStacks.map((stack) => ( +
navigate(`/stack/${stack.id}`)} + > +
+
+
+
+ {stack.name} + {stack.className && {stack.className}} +
-
- ))} - {shouldShowMoreStacks && ( - - )} -
+ ))} + {(hasMoreStacks || canCollapseStacks) && ( + + )} +
)} @@ -179,29 +178,32 @@ const Home = () => {

No classes yet. Add your first!

) : ( -
- {visibleClasses.map((classItem) => ( -
navigate(`/class/${classItem.id}`)} - > -
- - {classItem.stackCount > 0 && ( - {classItem.stackCount} - )} +
+ {visibleClasses.map((classItem) => ( +
navigate(`/class/${classItem.id}`)} + > +
+ + {classItem.stackCount > 0 && ( + {classItem.stackCount} + )} +
+ {classItem.name}
- {classItem.name} -
- ))} - {shouldShowMoreClasses && ( - - )} -
+ ))} + {(hasMoreClasses || canCollapseClasses) && ( + + )} +
)}
diff --git a/client/src/pages/LoginSignup.js b/client/src/pages/LoginSignup.js index 8d27113..16e6044 100644 --- a/client/src/pages/LoginSignup.js +++ b/client/src/pages/LoginSignup.js @@ -11,7 +11,7 @@ const LoginSignup = () => { const [message, setMessage] = useState(''); const [loading, setLoading] = useState(false); const [loginForm, setLoginForm] = useState({ username: '', password: '' }); - const [registerForm, setRegisterForm] = useState({ username: '', password: '' }); + const [registerForm, setRegisterForm] = useState({ username: '', password: '', email: '' }); useEffect(() => { if (authToken) { @@ -66,22 +66,26 @@ const LoginSignup = () => { setAuthToken(token); upsertLocalUser(sessionUsername); navigate('/home'); - } - catch (error) { + } catch (error) { setMessage(error?.payload?.message || error?.message || 'Incorrect username or password.'); - } - finally { + } finally { setLoading(false); } }; const handleRegister = async () => { - const { username, password } = registerForm; + const { username, password, email } = registerForm; const trimUsername = username.trim(); const trimPassword = password.trim(); + const trimEmail = email.trim().toLowerCase(); - if (!trimUsername || !trimPassword) { - setMessage('Please enter username and password.'); + if (!trimUsername || !trimPassword || !trimEmail) { + setMessage('Please enter username, password, and email.'); + return; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimEmail)) { + setMessage('Please enter a valid email address.'); return; } @@ -90,7 +94,7 @@ const LoginSignup = () => { try { const result = await apiRequest('/auth/register', { method: 'POST', - body: JSON.stringify({ username: trimUsername, password: trimPassword }), + body: JSON.stringify({ username: trimUsername, password: trimPassword, email: trimEmail }), }); const token = result?.token || ''; @@ -100,11 +104,9 @@ const LoginSignup = () => { setAuthToken(token); upsertLocalUser(sessionUsername); navigate('/home'); - } - catch (error) { + } catch (error) { setMessage(error?.payload?.message || error?.message || 'Registration failed.'); - } - finally { + } finally { setLoading(false); } }; @@ -176,6 +178,16 @@ const LoginSignup = () => { disabled={loading} >
+
+

Email

+ setRegisterForm((prev) => ({ ...prev, email: event.target.value }))} + disabled={loading} + > +

Password

{ const location = useLocation(); + const navigate = useNavigate(); const initialClassName = location.state?.className ?? ''; const [className, setClassName] = useState(initialClassName); const [isStacksOpen, setIsStacksOpen] = useState(false); const [selectedStackIds, setSelectedStackIds] = useState([]); - - const stacks = useMemo( - () => [ - { id: 1, name: 'Midterm' }, - { id: 2, name: 'Final' }, - { id: 3, name: 'Module 5' }, - { id: 4, name: 'Unit 6 Vocabulary' }, - { id: 5, name: 'Module 4' }, - { id: 6, name: 'Module 3' }, - { id: 7, name: 'Module 1' }, - { id: 8, name: 'Module 2' }, - ], - [] - ); + const [stacks, setStacks] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [feedback, setFeedback] = useState(''); - const selectedStacks = useMemo( - () => stacks.filter((stack) => selectedStackIds.includes(stack.id)), - [stacks, selectedStackIds] - ); + useEffect(() => { + apiRequest('/account/user') + .then((res) => setStacks(res.stacks || [])) + .catch(() => setStacks([])); + }, []); + const selectedStacks = stacks.filter((stack) => selectedStackIds.includes(stack._id)); const toggleStackSelection = (stackId) => { setSelectedStackIds((prev) => @@ -39,6 +33,26 @@ const NewClass = () => { ); }; + const handleCreateClass = async () => { + if (!className.trim()) { + setFeedback('Please enter a class name.'); + return; + } + setIsSaving(true); + setFeedback(''); + try { + await apiRequest('/class/create', { + method: 'POST', + body: JSON.stringify({ name: className.trim() }), + }); + navigate('/home'); + } catch (err) { + setFeedback(err.message || 'Failed to create class.'); + } finally { + setIsSaving(false); + } + }; + return (
{ {isStacksOpen && (
{stacks.map((stack) => { - const isSelected = selectedStackIds.includes(stack.id); + const isSelected = selectedStackIds.includes(stack._id); return ( @@ -109,8 +123,14 @@ const NewClass = () => {
-
diff --git a/client/src/pages/NewStack.css b/client/src/pages/NewStack.css index f2769dc..dc21687 100644 --- a/client/src/pages/NewStack.css +++ b/client/src/pages/NewStack.css @@ -480,17 +480,56 @@ text-align: left; } +.new-stack-modal-class-button-new-class { + padding: 15px 20px; + border-radius: 10px; + border: 2px dashed var(--border-muted); + background-color: var(--bg-card-secondary); + color: var(--text-primary); + cursor: pointer; + font-size: 16px; + font-weight: 600; + transition: all 0.25s ease-in-out; + text-align: left; +} + .new-stack-modal-class-button:active { background-color: var(--color-primary); border-color: var(--color-primary); } +.new-stack-modal-class-button-new-class:active { + background-color: var(--bg-card); + border-color: var(--color-primary); +} + .new-stack-modal-class-button.selected { background-color: var(--color-primary); border-color: var(--color-primary); color: var(--text-primary); } +.new-stack-modal-class-button-new-class.selected { + background-color: var(--color-primary); + border-color: var(--color-primary); + color: var(--text-primary); +} + +.new-stack-modal-class-name-input { + width: 100%; + padding: 12px 15px; + border-radius: 10px; + border: 2px solid var(--color-primary); + background-color: var(--bg-card); + color: var(--text-primary); + font-size: 15px; + margin-bottom: 20px; +} + +.new-stack-modal-class-name-input::placeholder { + color: var(--text-secondary); +} + .new-stack-modal-actions { display: grid; grid-template-columns: 1fr 1fr; @@ -555,4 +594,10 @@ .new-stack-modal-save-button:hover { background-color: var(--color-primary-dark); } + + .new-stack-modal-class-button-new-class:hover { + border-color: var(--color-primary); + background-color: var(--color-primary-dark); + } + } diff --git a/client/src/pages/NewStack.js b/client/src/pages/NewStack.js index 91d850f..e8888d3 100644 --- a/client/src/pages/NewStack.js +++ b/client/src/pages/NewStack.js @@ -26,6 +26,8 @@ const NewStack = () => { const [isSaving, setIsSaving] = useState(false); const [feedback, setFeedback] = useState(''); const [importFeedback, setImportFeedback] = useState(''); + const [isCreatingClass, setIsCreatingClass] = useState(false); + const [newClassName, setNewClassName] = useState(''); useEffect(() => { if (!getAuthToken()) { @@ -100,17 +102,34 @@ const NewStack = () => { const handleCloseModal = () => { setIsModalOpen(false); setSelectedClassId(null); + setIsCreatingClass(false); + setNewClassName(''); }; const handleSaveStack = async () => { if (isSaving) return; setIsSaving(true); try { + let classId = selectedClassId || null; + + if (isCreatingClass) { + if (!newClassName.trim()) { + setFeedback('Please enter a name for the new class.'); + setIsSaving(false); + return; + } + const created = await apiRequest('/class/create', { + method: 'POST', + body: JSON.stringify({ name: newClassName.trim() }), + }); + classId = created?.data?._id || null; + } + await apiRequest('/stack/create', { method: 'POST', body: JSON.stringify({ name: stackName.trim(), - classId: selectedClassId || null, + classId, cards: cards.map((card) => ({ term: card.term, definition: card.definition })), }), }); @@ -255,15 +274,33 @@ const NewStack = () => { {classes.map((cls) => ( ))} +
+ {isCreatingClass && ( + setNewClassName(e.target.value)} + autoFocus + /> + )} +
+ setIsDeleteModalOpen(false)} + title="Delete Stack" + > +

Are you sure you want to delete this stack? This action cannot be undone.

+
+ + +
+
+ setIsSettingsOpen(false)} title="Stack Settings"> {/* Membership Section */}
@@ -536,7 +573,6 @@ const StackView = () => {
)} - {/* Danger Zone */} {isOwner && (

Danger Zone

@@ -544,8 +580,8 @@ const StackView = () => { type="button" className="modal-option-button" onClick={() => { - handleUnavailableAction('Delete stack'); setIsSettingsOpen(false); + setIsDeleteModalOpen(true); }} >
diff --git a/client/src/styles/Modal.css b/client/src/styles/Modal.css index dab8c96..0634c3b 100644 --- a/client/src/styles/Modal.css +++ b/client/src/styles/Modal.css @@ -84,18 +84,46 @@ width: 100%; } +.modal-actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; +} + +.modal-actions .switch-button { + width: 100%; + height: 45px; + border-radius: 10px; + font-size: 16px; + background-color: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--bg-tertiary); + box-shadow: none; +} + +.modal-actions .login-button { + width: 100%; + height: 45px; + border-radius: 10px; + margin-top: 0; + background-color: var(--color-danger); + color: var(--text-primary); + border: 1px solid var(--color-danger-dark); +} + .modal-section { display: flex; flex-direction: column; } .modal-section-title { - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - color: var(--text-secondary); - padding-top: 15px; - padding-bottom: 2px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-secondary); + padding-top: 15px; + padding-bottom: 2px; } .modal-option-button { @@ -124,14 +152,14 @@ color: var(--text-primary); background-color: var(--bg-card); filter: brightness(0.98); - box-shadow: 0 2px 8px rgba(0,0,0,0.08); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .modal-option-button:hover { background-color: var(--bg-tertiary); border-color: var(--text-secondary); filter: brightness(0.98); - box-shadow: 0 2px 8px rgba(0,0,0,0.08); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .modal-danger-section .modal-option-button:hover { @@ -145,13 +173,21 @@ filter: none; box-shadow: none; } + + .modal-actions .switch-button:hover { + background-color: var(--bg-tertiary); + } + + .modal-actions .login-button:hover { + background-color: var(--color-danger-dark); + } } .modal-option-button:active, .modal-close-button:active { background: var(--bg-overlay-mid); filter: brightness(0.96); - box-shadow: 0 1px 4px rgba(0,0,0,0.10); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.10); transform: scale(0.98); } @@ -204,15 +240,15 @@ } @media (min-width: 650px) { - .modal-header { + .modal-header { padding: 18px 24px; - } + } - .modal-content { + .modal-content { padding: 22px 24px; - } + } - .modal-title { + .modal-title { font-size: 20px; } @@ -236,5 +272,5 @@ .modal-content { padding: 24px 28px; - } + } } diff --git a/server/src/controllers/auth/login.ts b/server/src/controllers/auth/login.ts index 28f2245..7d3be4d 100644 --- a/server/src/controllers/auth/login.ts +++ b/server/src/controllers/auth/login.ts @@ -10,6 +10,7 @@ const login: RequestHandler = async (req, res, next) => { { username: joi.instance.string().required(), password: joi.instance.string().required(), + email: joi.instance.string().email().optional(), }, req.body ) @@ -21,7 +22,6 @@ const login: RequestHandler = async (req, res, next) => { const username = String(req.body.username || '').trim().toLowerCase() const password = String(req.body.password || '') - // Get account from DB, and verify existance const account = await Account.findOne({ username }) if (!account) { @@ -31,7 +31,6 @@ const login: RequestHandler = async (req, res, next) => { }) } - // Verify password hash const passOk = await crypt.validate(password, account.password) if (!passOk) { @@ -41,10 +40,7 @@ const login: RequestHandler = async (req, res, next) => { }) } - // Generate access token const token = jwt.signToken({ uid: account._id, username: account.username }) - - // Remove password from response data const { password: _, ...accountData } = account.toObject() res.status(200).json({ diff --git a/server/src/controllers/auth/register.ts b/server/src/controllers/auth/register.ts index 164c4eb..ff72daa 100644 --- a/server/src/controllers/auth/register.ts +++ b/server/src/controllers/auth/register.ts @@ -10,6 +10,7 @@ const register: RequestHandler = async (req, res, next) => { { username: joi.instance.string().required(), password: joi.instance.string().required(), + email: joi.instance.string().email().required(), }, req.body ) @@ -20,8 +21,8 @@ const register: RequestHandler = async (req, res, next) => { const username = String(req.body.username || '').trim().toLowerCase() const password = String(req.body.password || '') + const email = String(req.body.email || '').trim().toLowerCase() - // Verify account username as unique const foundUsername = await Account.findOne({ username }) if (foundUsername) { @@ -31,17 +32,20 @@ const register: RequestHandler = async (req, res, next) => { }) } - // Encrypt password - const hash = await crypt.hash(password) + const foundEmail = await Account.findOne({ email }) + + if (foundEmail) { + return next({ + statusCode: 400, + message: 'An account already exists with that email', + }) + } - // Create account - const account = new Account({ username, password: hash }) + const hash = await crypt.hash(password) + const account = new Account({ username, password: hash, email }) await account.save() - // Generate access token const token = jwt.signToken({ uid: account._id, username: account.username }) - - // Exclude password from response const { password: _, ...data } = account.toObject() res.status(201).json({ diff --git a/server/src/controllers/class/add-stack.ts b/server/src/controllers/class/add-stack.ts new file mode 100644 index 0000000..961a66f --- /dev/null +++ b/server/src/controllers/class/add-stack.ts @@ -0,0 +1,47 @@ +import { type RequestHandler } from 'express' +import Class from '../../models/Class' +import Stack from '../../models/Stack' + +const addStack: RequestHandler = async (req, res, next) => { + try { + const { uid } = req.auth || {} + const { classId, stackId } = req.body + + if (!classId || !stackId) { + return next({ statusCode: 400, message: 'Missing classId or stackId' }) + } + + const classDoc = await Class.findById(classId) + + if (!classDoc) { + return next({ statusCode: 404, message: 'Class not found' }) + } + + const userEntry = classDoc.users.find(u => String(u.account) === uid) + + if (!userEntry || (userEntry.role !== 'owner' && userEntry.role !== 'editor')) { + return next({ statusCode: 403, message: 'You do not have permission to add stacks to this class' }) + } + + const stack = await Stack.findById(stackId) + + if (!stack) { + return next({ statusCode: 404, message: 'Stack not found' }) + } + + const isStackOwner = stack.users.some(u => String(u.account) === uid && u.role === 'owner') + + if (!isStackOwner) { + return next({ statusCode: 403, message: 'You do not own that stack' }) + } + + stack.class = classId + await stack.save() + + res.status(200).json({ message: 'Stack added to class' }) + } catch (error) { + next(error) + } +} + +export default addStack diff --git a/server/src/controllers/class/create-class.ts b/server/src/controllers/class/create-class.ts new file mode 100644 index 0000000..29b15df --- /dev/null +++ b/server/src/controllers/class/create-class.ts @@ -0,0 +1,38 @@ +import { type RequestHandler } from 'express' +import joi from '../../utils/joi' +import Class from '../../models/Class' + +const create: RequestHandler = async (req, res, next) => { + try { + const { uid } = req.auth || {} + + const validationError = await joi.validate( + { + name: joi.instance.string().trim().min(1).max(100).required(), + }, + req.body + ) + + if (validationError) return next(validationError) + + const { name } = req.body + + const newClass = await Class.create({ + name: name.trim(), + visibility: 'private', + users: [{ account: uid, role: 'owner' }], + }) + + res.status(201).json({ + message: 'Class created successfully', + data: { + _id: newClass._id, + name: newClass.name, + }, + }) + } catch (error) { + next(error) + } +} + +export default create diff --git a/server/src/controllers/class/delete-class.ts b/server/src/controllers/class/delete-class.ts new file mode 100644 index 0000000..99f153a --- /dev/null +++ b/server/src/controllers/class/delete-class.ts @@ -0,0 +1,33 @@ +import { type RequestHandler } from 'express' +import Class from '../../models/Class' + +const deleteClass: RequestHandler = async (req, res, next) => { + try { + const { uid } = req.auth || {} + const { classId } = req.body + + if (!classId) { + return next({ statusCode: 400, message: 'Missing classId' }) + } + + const classDoc = await Class.findById(classId) + + if (!classDoc) { + return next({ statusCode: 404, message: 'Class not found' }) + } + + const isOwner = classDoc.users.some(u => String(u.account) === uid && u.role === 'owner') + + if (!isOwner) { + return next({ statusCode: 403, message: 'Only the owner can delete this class' }) + } + + await classDoc.deleteOne() + + res.status(200).json({ message: 'Class deleted successfully' }) + } catch (error) { + next(error) + } +} + +export default deleteClass diff --git a/server/src/controllers/stack/delete-stack.ts b/server/src/controllers/stack/delete-stack.ts new file mode 100644 index 0000000..fc4cae4 --- /dev/null +++ b/server/src/controllers/stack/delete-stack.ts @@ -0,0 +1,35 @@ +import { type RequestHandler } from 'express' +import Stack from '../../models/Stack' +import Card from '../../models/Card' + +const deleteStack: RequestHandler = async (req, res, next) => { + try { + const { uid } = req.auth || {} + const { stackId } = req.body + + if (!stackId) { + return next({ statusCode: 400, message: 'Missing stackId' }) + } + + const stack = await Stack.findById(stackId) + + if (!stack) { + return next({ statusCode: 404, message: 'Stack not found' }) + } + + const isOwner = stack.users.some(u => String(u.account) === uid && u.role === 'owner') + + if (!isOwner) { + return next({ statusCode: 403, message: 'Only the owner can delete this stack' }) + } + + await Card.deleteMany({ stack: stackId }) + await stack.deleteOne() + + res.status(200).json({ message: 'Stack deleted successfully' }) + } catch (error) { + next(error) + } +} + +export default deleteStack diff --git a/server/src/models/Account.ts b/server/src/models/Account.ts index 2f425d9..da63d60 100644 --- a/server/src/models/Account.ts +++ b/server/src/models/Account.ts @@ -3,11 +3,13 @@ import { Account } from '../@types' const accountSchema = new Schema( { - email: { type: String, unique: true, sparse: true, lowercase: true, trim: true }, + email: { type: String, lowercase: true, trim: true }, username: { type: String, required: true, unique: true, lowercase: true }, password: { type: String, required: true } }, { timestamps: true } ) +accountSchema.index({ email: 1 }, { unique: true, partialFilterExpression: { email: { $exists: true, $nin: [null, ''] } } }) + export default model('Account', accountSchema) \ No newline at end of file diff --git a/server/src/routes/class.ts b/server/src/routes/class.ts index 2318609..fad86b0 100644 --- a/server/src/routes/class.ts +++ b/server/src/routes/class.ts @@ -1,13 +1,17 @@ import express from 'express' import checkOptionalBearerToken from '../middlewares/check-optional-bearer-token' +import checkBearerToken from '../middlewares/check-bearer-token' import errorHandler from '../middlewares/error-handler' import view from '../controllers/class/view-class' +import create from '../controllers/class/create-class' +import deleteClass from '../controllers/class/delete-class' +import addStack from '../controllers/class/add-stack' -// initialize router const router = express.Router() -// GET at path: http://localhost:8080/class/view -// user can see a class if it is public or if user is authenticated router.get('/view', [checkOptionalBearerToken], view, errorHandler) +router.post('/create', [checkBearerToken], create, errorHandler) +router.post('/delete', [checkBearerToken], deleteClass, errorHandler) +router.post('/add-stack', [checkBearerToken], addStack, errorHandler) export default router diff --git a/server/src/routes/stack.ts b/server/src/routes/stack.ts index fa2bdfc..151eefd 100644 --- a/server/src/routes/stack.ts +++ b/server/src/routes/stack.ts @@ -4,15 +4,12 @@ import checkOptionalBearerToken from '../middlewares/check-optional-bearer-token import errorHandler from '../middlewares/error-handler' import view from '../controllers/stack/view-stack' import create from '../controllers/stack/create-stack' +import deleteStack from '../controllers/stack/delete-stack' -// initialize router const router = express.Router() -// GET at path: http://localhost:8080/stack/view -// user can see a class if it is public or if user is authenticated router.get('/view', [checkOptionalBearerToken], view, errorHandler) - -// POST at path: http://localhost:8080/stack/create router.post('/create', [checkBearerToken], create, errorHandler) +router.post('/delete', [checkBearerToken], deleteStack, errorHandler) export default router diff --git a/server/src/utils/joi.ts b/server/src/utils/joi.ts index b541b0b..6cfbfd3 100644 --- a/server/src/utils/joi.ts +++ b/server/src/utils/joi.ts @@ -5,10 +5,8 @@ class Joi { async validate(schema: Record, body: Record) { try { - await this.instance.object(schema).validateAsync(body) + await this.instance.object(schema).unknown(true).validateAsync(body) } catch (error: any) { - console.log('❌ Joi validation error:', error.message) - return { statusCode: 400, message: error.message, From 4b060e010f1abcba13d7baf536d5db39a6aab0b6 Mon Sep 17 00:00:00 2001 From: JettNguyen Date: Tue, 31 Mar 2026 14:14:04 -0400 Subject: [PATCH 02/18] add back add button in classview.js --- client/src/pages/ClassView.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/src/pages/ClassView.js b/client/src/pages/ClassView.js index 3de8c2c..690b352 100644 --- a/client/src/pages/ClassView.js +++ b/client/src/pages/ClassView.js @@ -507,7 +507,14 @@ const ClassView = () => {
- +
@@ -583,7 +590,6 @@ const ClassView = () => { ); }); - // Insert add/see-more tile after the 3rd position when collapsed, otherwise show add tile if (shouldCollapse) { items.push( ) : ( -
+

Username

{ if (isLoading) { return (
- {isLoadingVisible && ( + {isLoadingVisible ? (

Loading profile...

+ ) : ( + ); diff --git a/client/src/pages/StackView.css b/client/src/pages/StackView.css index 51b0ba0..16bc355 100644 --- a/client/src/pages/StackView.css +++ b/client/src/pages/StackView.css @@ -92,7 +92,7 @@ font-size: 12px; font-weight: 600; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4); - z-index: 1500; + z-index: 900; } .stack-card-large { diff --git a/client/src/pages/StackView.js b/client/src/pages/StackView.js index 0a85d46..5acf383 100644 --- a/client/src/pages/StackView.js +++ b/client/src/pages/StackView.js @@ -261,11 +261,13 @@ const StackView = () => { if (isLoading) { return (
- {isLoadingVisible && ( + {isLoadingVisible ? (

Loading stack...

+ ) : ( + ); diff --git a/client/src/utils/api.js b/client/src/utils/api.js index 22d85d3..bd577f7 100644 --- a/client/src/utils/api.js +++ b/client/src/utils/api.js @@ -1,4 +1,23 @@ -const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:8080'; +const resolveApiBase = () => { + const configuredBase = process.env.REACT_APP_API_URL?.trim(); + + if (configuredBase) { + return configuredBase.replace(/\/+$/, ''); + } + + if (typeof window !== 'undefined') { + const { protocol, hostname } = window.location; + const apiPort = process.env.REACT_APP_API_PORT || '8080'; + + if (hostname && hostname !== 'localhost' && hostname !== '127.0.0.1') { + return `${protocol}//${hostname}:${apiPort}`; + } + } + + return 'http://localhost:8080'; +}; + +const API_BASE = resolveApiBase(); const TOKEN_KEY = 'stackd_auth_token'; const buildUrl = (path) => `${API_BASE}${path}`; diff --git a/server/tsconfig.json b/server/tsconfig.json index a4c4e53..cfd5e73 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", + "module": "Node16", + "moduleResolution": "node16", "strict": true, "skipLibCheck": true, "resolveJsonModule": true, From 01b3cbca00a8e3802972bd43bbc51c7dd861cda3 Mon Sep 17 00:00:00 2001 From: JettNguyen Date: Sun, 5 Apr 2026 15:48:02 -0400 Subject: [PATCH 04/18] add ability to generate stacks using gemini ai, update the frontend to accommodate file uploads and paste text --- client/src/pages/ClassView.css | 2 +- client/src/pages/LoginSignup.css | 2 +- client/src/pages/NewClass.css | 2 +- client/src/pages/NewStack.css | 168 ++++++++++- client/src/pages/NewStack.js | 279 +++++++++++++++++- client/src/styles/global.css | 5 +- client/src/utils/api.js | 16 +- server/package-lock.json | 167 ++++++++++- server/package.json | 5 +- server/src/constants/index.ts | 5 +- .../src/controllers/stack/generate-stack.ts | 182 ++++++++++++ server/src/index.ts | 3 - server/src/routes/stack.ts | 4 + 13 files changed, 818 insertions(+), 22 deletions(-) create mode 100644 server/src/controllers/stack/generate-stack.ts diff --git a/client/src/pages/ClassView.css b/client/src/pages/ClassView.css index 01d741c..3a01391 100644 --- a/client/src/pages/ClassView.css +++ b/client/src/pages/ClassView.css @@ -323,7 +323,7 @@ margin: 10px 0 0; font-size: 13px; font-weight: 600; - color: var(--color-primary); + color: var(--color-danger); text-align: center; } diff --git a/client/src/pages/LoginSignup.css b/client/src/pages/LoginSignup.css index 9b11a1e..ddb6cd3 100644 --- a/client/src/pages/LoginSignup.css +++ b/client/src/pages/LoginSignup.css @@ -73,7 +73,7 @@ } .login-signup-feedback { - color: var(--color-primary); + color: var(--color-danger); font-size: 14px; margin-top: 10px; } diff --git a/client/src/pages/NewClass.css b/client/src/pages/NewClass.css index fbdbbb7..1236d56 100644 --- a/client/src/pages/NewClass.css +++ b/client/src/pages/NewClass.css @@ -161,7 +161,7 @@ margin-top: -10px; margin-bottom: 20px; font-size: 14px; - color: #ffb4a8; + color: var(--color-danger); font-weight: 600; } diff --git a/client/src/pages/NewStack.css b/client/src/pages/NewStack.css index dc21687..d660a3d 100644 --- a/client/src/pages/NewStack.css +++ b/client/src/pages/NewStack.css @@ -85,7 +85,7 @@ } .new-stack-feedback { - color: var(--color-primary); + color: var(--color-danger); font-size: 14px; margin-top: -10px; margin-bottom: 20px; @@ -205,6 +205,170 @@ color: var(--text-secondary); } +.ai-upload-dropzone { + display: grid; + place-items: center; + min-height: 120px; + text-align: center; + cursor: pointer; + border-style: dashed; +} + +.ai-upload-dropzone-dragover { + border-style: solid; +} + +.ai-upload-hidden-input { + display: none; +} + +.ai-file-select-row { + display: flex; + align-items: center; + gap: 10px; +} + +.ai-file-name { + font-size: 12px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.ai-card-count-wrap { + margin-top: 15px; + margin-bottom: 15px; +} + +.ai-card-count-heading-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.ai-card-count-label { + margin-bottom: 10px; +} + +.ai-card-count-auto-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 14px; + color: var(--text-primary); + user-select: none; + padding: 5px; + margin-bottom: 10px; + border-radius: 25px; + cursor: pointer; +} + +.ai-card-count-auto-checkbox { + appearance: none; + -webkit-appearance: none; + width: 45px; + height: 25px; + border-radius: 25px; + background-color: var(--bg-secondary); + border: 1px solid var(--border-muted); + position: relative; + transition: background-color 0.2s ease, border-color 0.2s ease; + cursor: pointer; +} + +.ai-card-count-auto-checkbox::before { + content: ''; + position: absolute; + top: 50%; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--text-secondary); + transform: translate(0, -50%); + transition: transform 0.2s ease, background-color 0.2s ease; +} + +.ai-card-count-auto-checkbox:checked { + background-color: var(--color-primary-darker); + border-color: var(--color-primary); +} + +.ai-card-count-auto-checkbox:checked::before { + transform: translate(20px, -50%); + background-color: var(--color-primary); +} + +.ai-card-count-auto-checkbox:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.ai-card-count-slider { + --ai-slider-progress: 0%; + width: 100%; + height: 10px; + appearance: none; + -webkit-appearance: none; + border-radius: 5px; + background: linear-gradient( + to right, + var(--color-primary) 0%, + var(--color-primary) var(--ai-slider-progress), + var(--bg-card) var(--ai-slider-progress), + var(--bg-card) 100% + ); + padding: 0; + cursor: pointer; + filter: drop-shadow(0 0 5px rgba(0, 0, 0, 0.5)); +} + +.ai-card-count-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 25px; + height: 25px; + border-radius: 50%; + background: var(--color-primary); +} + +.ai-card-count-slider::-moz-range-thumb { + width: 25px; + height: 25px; + border-radius: 50%; + background: var(--color-primary); +} + +.ai-card-count-slider::-moz-range-track { + height: 10px; + border-radius: 25px; + background: var(--bg-card); + border: none; +} + +.ai-file-error { + color: var(--color-danger); +} + +.ai-or-separator { + text-align: center; + margin-top: 15px; + margin-bottom: 0; + color: var(--text-secondary); + font-size: 12px; +} + +.ai-paste-input { + margin-top: 10px; +} + +.ai-notes-input { + margin-top: 15px; +} + .flashcards-panel { background-color: var(--bg-secondary); border-radius: 20px; @@ -457,7 +621,7 @@ .new-stack-modal-subtitle { font-size: 14px; color: var(--text-secondary); - margin: 0 0 20px; + margin: 0 0 10px; } .new-stack-modal-classes-grid { diff --git a/client/src/pages/NewStack.js b/client/src/pages/NewStack.js index e8888d3..3bfa909 100644 --- a/client/src/pages/NewStack.js +++ b/client/src/pages/NewStack.js @@ -1,10 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faChevronDown, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; import { apiRequest, getAuthToken } from '../utils/api'; import Breadcrumbs from '../components/Breadcrumbs'; import Modal from '../components/Modal'; +import useDelayedSpinner from '../utils/useDelayedSpinner'; import './NewStack.css'; const NewStack = () => { @@ -20,14 +21,31 @@ const NewStack = () => { const [importText, setImportText] = useState(''); const [cards, setCards] = useState(initialCards); const [isImportOpen, setIsImportOpen] = useState(false); + const [isAiOpen, setIsAiOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedClassId, setSelectedClassId] = useState(initialSelectedClassId); const [classes, setClasses] = useState([]); const [isSaving, setIsSaving] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); const [feedback, setFeedback] = useState(''); const [importFeedback, setImportFeedback] = useState(''); + const [importFeedbackIsError, setImportFeedbackIsError] = useState(false); + const [aiFeedback, setAiFeedback] = useState(''); + const [aiFeedbackIsError, setAiFeedbackIsError] = useState(false); const [isCreatingClass, setIsCreatingClass] = useState(false); const [newClassName, setNewClassName] = useState(''); + const [aiCardCount, setAiCardCount] = useState(10); + const [aiAutoCardCount, setAiAutoCardCount] = useState(false); + const [aiNotes, setAiNotes] = useState(''); + const [aiFileError, setAiFileError] = useState(''); + const [aiFile, setAiFile] = useState(null); + const [aiPastedText, setAiPastedText] = useState(''); + const [isDesktopUpload, setIsDesktopUpload] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const isGeneratingVisible = useDelayedSpinner(isGenerating, 1000); + const aiSliderRef = useRef(null); + + const aiFileInputId = 'ai-generate-file-input'; useEffect(() => { if (!getAuthToken()) { @@ -39,6 +57,30 @@ const NewStack = () => { .catch(() => navigate('/', { replace: true })); }, [navigate]); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const update = () => setIsDesktopUpload(window.innerWidth >= 650); + + update(); + + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + useEffect(() => { + if (!aiSliderRef.current) { + return; + } + + const min = 5; + const max = 75; + const progress = ((aiCardCount - min) / (max - min)) * 100; + aiSliderRef.current.style.setProperty('--ai-slider-progress', `${progress}%`); + }, [aiCardCount, isAiOpen]); + const parseImport = (text) => { return text .split(/\r?\n/) @@ -55,14 +97,111 @@ const NewStack = () => { .filter(Boolean); }; - const handleImport = () => { - const parsed = parseImport(importText); + const applyParsedCards = (rawText) => { + const parsed = parseImport(rawText); + if (parsed.length === 0) { setImportFeedback('No cards found. Check the format and try again.'); + setImportFeedbackIsError(true); + setAiFeedback('No cards found. Check the format and try again.'); + setAiFeedbackIsError(true); return; } + setCards(parsed.map((card, index) => ({ id: index + 1, ...card }))); setImportFeedback(`✓ Imported ${parsed.length} card${parsed.length === 1 ? '' : 's'}`); + setImportFeedbackIsError(false); + return parsed.length; + }; + + const handleImport = () => { + applyParsedCards(importText); + }; + + const MAX_FILE_SIZE = 15 * 1024 * 1024; + + const setAiSelectedFile = (file) => { + if (file && file.size > MAX_FILE_SIZE) { + setAiFile(null); + setAiFileError(`File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Maximum size is 15 MB.`); + const input = document.getElementById(aiFileInputId); + if (input) input.value = ''; + return; + } + setAiFile(file || null); + setAiFileError(''); + setAiFeedback(''); + setAiFeedbackIsError(false); + }; + + const handleSelectAiFile = (event) => { + setAiSelectedFile(event.target.files?.[0]); + }; + + const handleGenerateCards = async () => { + if (!aiFile && !aiPastedText.trim()) { + setAiFeedback('Please upload a file or paste some text first.'); + setAiFeedbackIsError(true); + return; + } + + const requestedCardCount = Number(aiCardCount); + + if (!aiAutoCardCount && (!Number.isInteger(requestedCardCount) || requestedCardCount <= 0)) { + setAiFeedback('Please choose a valid card count.'); + setAiFeedbackIsError(true); + return; + } + + if (isGenerating) { + return; + } + + setIsGenerating(true); + setAiFeedback(''); + setAiFeedbackIsError(false); + + try { + const formData = new FormData(); + if (aiFile) formData.append('file', aiFile); + if (aiPastedText.trim()) formData.append('pastedText', aiPastedText.trim()); + formData.append('cardCount', aiAutoCardCount ? 'auto' : String(requestedCardCount)); + + if (aiNotes.trim()) { + formData.append('notes', aiNotes.trim()); + } + + const responseText = await apiRequest('/stack/generate', { + method: 'POST', + body: formData, + }); + + const normalizedImportText = String(responseText || '') + .trim() + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join('\n'); + + const importedCount = applyParsedCards(normalizedImportText); + + if (importedCount) { + setImportText(normalizedImportText); + setAiFeedback(`✓ Generated ${importedCount} card${importedCount === 1 ? '' : 's'}`); + setAiFeedbackIsError(false); + } + } catch (err) { + setAiFeedback(err?.message || 'Failed to generate cards. Please try again.'); + setAiFeedbackIsError(true); + } finally { + setIsGenerating(false); + } + }; + + const handleDropAiFile = (event) => { + event.preventDefault(); + setIsDragOver(false); + setAiSelectedFile(event.dataTransfer.files?.[0]); }; const handleCardChange = (id, field, value) => { @@ -208,7 +347,139 @@ const NewStack = () => { Import
- {importFeedback &&

{importFeedback}

} + {importFeedback &&

{importFeedback}

} +
+ )} + + +
+ + {isAiOpen && ( +
+

Upload class materials and have AI generate cards for you!

+ + {isDesktopUpload ? ( +
{ + event.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} + onDrop={handleDropAiFile} + onClick={() => document.getElementById(aiFileInputId)?.click()} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + document.getElementById(aiFileInputId)?.click(); + } + }} + > + {aiFile ? aiFile.name : 'Drop PDF, image, or text file here'} +
+ ) : ( +
+ + + {aiFile && {aiFile.name}} +
+ )} + + {isDesktopUpload && ( +
+ + +
+ )} + + {aiFileError &&

{aiFileError}

} + +

- or paste text below -

+