diff --git a/frontend/src/App.css b/frontend/src/App.css index 33b6b21..6a701f0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -290,6 +290,34 @@ --shadow-inset: inset 0 1px 3px rgba(0,0,0,0.6); } +[data-theme="blossom"] { + --bg: #fff7fb; + --text: #4a2340; + --text-muted: #9a6f8d; + --panel-bg: #fff0f7; + --card-bg: #fff4fa; + --box-bg: #ffe8f3; + --border: #f3bfd8; + --border-subtle: #f8d9e8; + --input-bg: #ffffff; + --input-border: #efb6d2; + --input-text: #4a2340; + + --primary: #ec6aa7; + --primary-hover: #d94f93; + --btn-primary: #ec6aa7; + --btn-primary-hover: #d94f93; + --btn-download: #f59ac2; + --btn-download-hover: #ea78ad; + + --btn-clear: #ff7f9f; + --btn-clear-hover: #eb5d84; + --shadow-sm: 0 1px 2px rgba(236, 106, 167, 0.08); + --shadow-md: 0 4px 12px rgba(236, 106, 167, 0.12); + --shadow-lg: 0 10px 28px rgba(236, 106, 167, 0.16); + --shadow-inset: inset 0 1px 3px rgba(236, 106, 167, 0.08); +} + /* ========================================================================== BASE STYLES ========================================================================== */ @@ -2620,12 +2648,14 @@ right panel - youtube resources } .video-card-sm.compact .video-thumb-sm { - width: 100%; + aspect-ratio: 16 / 9; + border-radius: 12px; } .video-card-sm.compact .video-thumb-sm img { width: 100%; - height: 54px; + height: 100%; + object-fit: cover; } .video-card-sm.compact .play-icon { @@ -2650,15 +2680,19 @@ right panel - youtube resources } .video-thumb-sm { + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 18px; position: relative; - flex-shrink: 0; - width: 84px; + background: #00000022; } .video-thumb-sm img { - width: 84px; - height: 50px; + width: 100%; + height: 100%; object-fit: cover; + object-position: center; display: block; } @@ -2904,17 +2938,6 @@ three - column responsive } } -@media (max-width: 1040px) { - .video-thumb-sm, - .video-thumb-sm img { - width: 88px; - } - - .video-thumb-sm img { - height: 58px; - } -} - @media (max-width: 860px) { .app-body { grid-template-columns: 1fr; @@ -3172,4 +3195,362 @@ three - column responsive .save-status.saved { color: #16a34a; -} \ No newline at end of file +} + +/* video card hover transitions */ +.video-card-sm { + transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast); +} + +.video-card-sm:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +/* smooth panel show/hide transitions */ +.left-panel { + transition: width var(--transition-slow), opacity var(--transition-slow); +} +.right-panel { + transition: width var(--transition-slow), opacity var(--transition-slow); +} +.left-panel, +.right-panel { + will-change: width; +} + +/* mobile responsiveness improvement */ +@media (max-width: 768px) { + .app-body { + grid-template-columns: 1fr !important; + grid-template-rows: auto; + overflow-y: auto; + } + .left-panel, + .right-panel { + max-height: 300px; + border: none; + border-bottom: 1px solid var(--border); + } + .center-panel { + min-height: 60vh; + } + .pdf-container { + padding: var(--space-sm); + } + .workspace-topbar { + flex-wrap: wrap; + gap: var(--space-sm); + } + .left-panel-footer { + padding: var(--space-sm); + } + .btn-compile { + font-size: 0.8rem; + padding: 0.5rem; + + } + .modal-box { + width: 95%; + padding: var(--space-md); + } + .modal-box iframe { + height: 220px; + } +} + +/* focus ring styles for keyboard navigation */ +:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15); +} + +/* Remove default outline since we handle it above */ +button:focus:not(:focus-visible), +input:focus:not(:focus-visible), +select:focus:not(:focus-visible) { + outline: none; +} + +/*scrollbar styling */ +.left-panel-scroll, +.right-panel-scroll, +.pdf-preview-scroll, +.formula-reorder-panel { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.left-panel-scroll::-webkit-scrollbar, +.right-panel-scroll::-webkit-scrollbar, +.pdf-preview-scroll::-webkit-scrollbar, +.formula-reorder-panel::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +.left-panel-scroll::-webkit-scrollbar-track, +.right-panel-scroll::-webkit-scrollbar-track, +.pdf-preview-scroll::-webkit-scrollbar-track, +.formula-reorder-panel::-webkit-scrollbar-track { + background: transparent; +} + +.left-panel-scroll::-webkit-scrollbar-thumb, +.right-panel-scroll::-webkit-scrollbar-thumb, +.pdf-preview-scroll::-webkit-scrollbar-thumb, +.formula-reorder-panel::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: var(--radius-full); +} + +.left-panel-scroll::-webkit-scrollbar-thumb:hover, +.right-panel-scroll::-webkit-scrollbar-thumb:hover, +.pdf-preview-scroll::-webkit-scrollbar-thumb:hover, +.formula-reorder-panel::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Muted text contrast improvement */ +.text-muted, +.v-channel, +.right-panel-empty, +.reorder-instructions, +.subtle-copy, +.pdf-toolbar-note, +.snapshot-card-meta, +.inline-video-status { + color: var(--text-muted); + opacity: 1; + +} +[data-theme="dark"] .text-muted, +[data-theme="dark"] .v-channel, +[data-theme="dark"] .right-panel-empty { + color: #a1a1aa; +} +[data-theme="light"] .text-muted, +[data-theme="light"] .v-channel, +[data-theme="light"] .right-panel-empty { + color: #52525b; +} + +/* Right Panel Empty State */ +.right-panel-empty-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: var(--space-xl) var(--space-md); + gap: var(--space-sm); +} + +.right-panel-empty-icon { + font-size: 2.5rem; + line-height: 1; + opacity: 0.6; +} + +.right-panel-empty-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.right-panel-empty-hint { + font-size: 0.75rem; + color: var(--text-muted); + margin: 0; + line-height: 1.5; +} + +/*count badge for right panel */ +.right-panel-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.right-panel-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--primary); + color: white; + font-size: 0.65rem; + font-weight: 700; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: var(--radius-full); + line-height: 1; +} + +/* clear search button*/ +.btn-clear-search { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 0.65rem; + padding: 2px 5px; + cursor: pointer; + transition: all var(--transition-fast); + line-height: 1; +} + +.btn-clear-search:hover { + border-color: var(--btn-clear); + color: var(--btn-clear); + background: rgba(239, 68, 68, 0.08); +} + +.title-char-counter { + text-align: right; + font-size: 0.68rem; + color: var(--text-muted); + margin-top: 0.2rem; +} + +.title-char-counter-warn { + color: var(--btn-clear); + font-weight: 600; +} + +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1.25rem; + border-radius: var(--radius-lg); + font-size: 0.85rem; + font-weight: 500; + font-weight: 500; + box-shadow: var(--shadow-lg); + z-index: 9999; + animation: toast-in 0.3s ease forwards; +} + +.toast-success { + background: #10b981; + color: white; +} + +.toast-error { + background: var(--btn-clear); + color: white; +} + +.toast-icon { + font-size: 1rem; + font-weight: 700; + +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(12px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.layout-control + .layout-control { + padding-top: 0.5rem; + margin-top: 0.5rem; + border-top: 1px solid var(--border-subtle); +} + +/* select all / deselect all buttons */ +.class-select-all-row { + display: flex; + gap: 0.4rem; + margin-bottom: 0.5rem; +} + +.btn-select-all { + flex: 1; + padding: 0.3rem 0.5rem; + font-size: 0.72rem; + font-weight: 500; + border-radius: var(--radius-sm); + border: 1px solid var(--primary); + background: transparent; + color: var(--primary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-select-all:hover { + background: var(--primary); + color: white; +} + +.btn-deselect-all { + border-color: var(--btn-clear); + color: var(--btn-clear); +} + +.btn-deselect-all:hover { + background: var(--btn-clear); + color: white; +} + +.pdf-toolbar-note { + font-size: 0.75rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + min-width: 80px; +} + + +.pdf-scroll-top-btn { + position: sticky; + bottom: 20px; + left: 100%; + transform: translateX(-40px); + width: 40px; + height: 40px; + border-radius: 9999px; + background: var(--primary, #3b82f6); + color: white; + border: none; + font-size: 1.5rem; + font-weight: bold; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.pdf-scroll-top-btn:hover { + background: var(--primary-hover, #2563eb); + transform: translateX(-40px) scale(1.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.pdf-scroll-top-btn:active { + transform: translateX(-40px) scale(0.98); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 30377b3..624cf0b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -149,6 +149,7 @@ const THEMES = [ { id: 'neon', label: '🩵 neon'}, { id: 'galaxy', label: '🌌 Galaxy' }, {id: 'crimson', label: '❤️ Red' }, + {id: 'blossom', label: '🌸 Blossom'}, ]; function App() { diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx index 830ca93..95a0017 100644 --- a/frontend/src/components/CreateCheatSheet.jsx +++ b/frontend/src/components/CreateCheatSheet.jsx @@ -352,6 +352,7 @@ const SectionVideoPicks = ({ searchedVideos = [], onOpen, onSearchMore, + onClearSearch, isSearching = false, searchError = '', hasSearched = false, @@ -392,19 +393,30 @@ const SectionVideoPicks = ({ )} {allowSearch && ( -
- -
- )} +
+ + {hasSearched && !isSearching && searchedVideos.length > 0 && ( + + )} +
+)} {isSearching && !searchedVideos.length && !searchError && (

Searching…

@@ -453,11 +465,31 @@ const FormulaSelection = ({ return (
setClassesOpen((current) => !current)} - countBadge={selectedCount > 0 ? `${selectedCount}` : null} - > + title="Select classes" + isOpen={classesOpen} + onToggle={() => setClassesOpen((current) => !current)} + countBadge={selectedCount > 0 ? `${selectedCount}` : null} +> +
+ + +
{classesData.map((cls) => { const isChecked = !!selectedClasses[cls.name]; @@ -703,29 +735,47 @@ const PdfPreview = ({ pdfBlob, compileError, isCompiling, layoutSignature }) => const [containerHeight, setContainerHeight] = useState(null); const [zoom, setZoom] = useState(DEFAULT_PDF_ZOOM); const [viewMode, setViewMode] = useState('custom'); + const [showScrollTop, setShowScrollTop] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const scrollRef = useRef(null); - const clampZoom = (value) => Math.min(2, Math.max(0.5, value)); + const handlePdfScroll = () => { + if (scrollRef.current) { + setShowScrollTop(scrollRef.current.scrollTop > 300); + } + const pages = scrollRef.current?.querySelectorAll('.pdf-page'); + if (pages?.length) { + const containerTop = scrollRef.current.getBoundingClientRect().top; + let current = 1; + pages.forEach((page, index) => { + const pageTop = page.getBoundingClientRect().top - containerTop; + if (pageTop <= 100) current = index + 1; + }); + setCurrentPage(current); + } + }; + const scrollToTop = () => { + scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const clampZoom = (value) => Math.min(2, Math.max(0.5, value)); const handleZoomOut = () => { setViewMode('custom'); - setZoom((currentZoom) => clampZoom(currentZoom - 0.15)); + setZoom((z) => clampZoom(z - 0.15)); }; - const handleZoomIn = () => { setViewMode('custom'); - setZoom((currentZoom) => clampZoom(currentZoom + 0.15)); + setZoom((z) => clampZoom(z + 0.15)); }; - const handleResetZoom = () => { setViewMode('custom'); setZoom(DEFAULT_PDF_ZOOM); }; - const handleFitToWidth = () => { setViewMode('width'); setZoom(1); }; - const handleFitToHeight = () => { setViewMode('height'); setZoom(1); @@ -734,38 +784,33 @@ const PdfPreview = ({ pdfBlob, compileError, isCompiling, layoutSignature }) => const pageWidth = containerWidth && viewMode !== 'height' ? Math.max(240, Math.round(containerWidth * (viewMode === 'width' ? 1 : zoom))) : undefined; - const pageHeight = containerHeight && viewMode === 'height' ? Math.max(320, Math.round((containerHeight - 24) * zoom)) : undefined; const updatePreviewSize = useCallback(() => { const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - - setContainerWidth(rect.width); - setContainerHeight(rect.height); + if (rect) { + setContainerWidth(rect.width); + setContainerHeight(rect.height); + } }, []); useEffect(() => { const container = containerRef.current; if (!container) return; - updatePreviewSize(); - if (!window.ResizeObserver) { window.addEventListener('resize', updatePreviewSize); return () => window.removeEventListener('resize', updatePreviewSize); } - const resizeObserver = new window.ResizeObserver((entries) => { const entry = entries[0]; - if (!entry) return; - - setContainerWidth(entry.contentRect.width); - setContainerHeight(entry.contentRect.height); + if (entry) { + setContainerWidth(entry.contentRect.width); + setContainerHeight(entry.contentRect.height); + } }); - resizeObserver.observe(container); return () => resizeObserver.disconnect(); }, [updatePreviewSize]); @@ -777,20 +822,41 @@ const PdfPreview = ({ pdfBlob, compileError, isCompiling, layoutSignature }) => return (
- Use the controls to adjust the preview. + + {numPages ? `Page ${currentPage} of ${numPages}` : 'Use the controls to adjust the preview.'} +
- -
-
+
-
- {compileError ? ( -
- Compilation: Error:

- {compileError} -
- ) : pdfBlob ? ( - setNumPages(numPages)} - loading={
Loading PDF...
} - error={
Failed to load PDF.
} +
+ {compileError ? ( +
+ Compilation Error: +
+
+ {compileError} +
+ ) : pdfBlob ? ( + <> + setNumPages(numPages)} + loading={
Loading PDF…
} + error={
Failed to load PDF.
} > - {Array.from(new Array(numPages), (_, index) => ( - ( + className="pdf-page" width={pageWidth} height={pageHeight} - /> - + /> ))} -
- ) : ( -
- Compile the PDF to see your preview. -
- )} + + {showScrollTop && ( + + )} + + ) : ( +
Compile the PDF to see your preview.
+ )}
+ {isCompiling && (
{isCompiling ? '↻' : '⚡'} @@ -1518,6 +1672,7 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS onClick={handleSave} className="btn history-btn" disabled={isSaving} + title="Save (Ctrl + S)" > {isSaving ? 'Saving…' : 'Save'} @@ -1686,10 +1841,21 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS
)} + {toast && ( +
+ + {toast.type === 'success' ? '✓' : '✕'} + + {toast.message} +
+ )} ); }; diff --git a/frontend/src/components/CreateCheatSheet.test.jsx b/frontend/src/components/CreateCheatSheet.test.jsx index 126a83f..9ff7bbc 100644 --- a/frontend/src/components/CreateCheatSheet.test.jsx +++ b/frontend/src/components/CreateCheatSheet.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent, within } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import CreateCheatSheet from './CreateCheatSheet'; import { useFormulas } from '../hooks/formulas'; @@ -59,6 +59,8 @@ describe('CreateCheatSheet Component', () => { setSpacing: vi.fn(), margins: '0.15in', setMargins: vi.fn(), + orientation: 'portrait', + setOrientation: vi.fn(), pdfBlob: null, isGenerating: false, isCompiling: false, @@ -151,6 +153,25 @@ describe('CreateCheatSheet Component', () => { expect(screen.queryByLabelText(/Generated LaTeX Code:/i)).not.toBeInTheDocument(); }); + it('restores saved orientation from initial data', () => { + const setOrientation = vi.fn(); + + useLatex.mockReturnValue({ + ...mockUseLatex, + setOrientation, + }); + + render( + , + ); + + expect(setOrientation).toHaveBeenCalledWith('landscape'); + }); + it('compiles existing manual content without regenerating', () => { const handleCompileOnlyMock = vi.fn(); const selectedFormulas = [{ name: 'test' }]; @@ -196,6 +217,50 @@ describe('CreateCheatSheet Component', () => { expect(handleCompileOnlyMock).toHaveBeenCalledWith(selectedFormulas); }); + it('compiles on Ctrl+Enter and prevents the browser default action', () => { + const handlePreviewMock = vi.fn(); + const selectedFormulas = [{ name: 'test' }]; + + useLatex.mockReturnValue({ ...mockUseLatex, handlePreview: handlePreviewMock }); + useFormulas.mockReturnValue({ + ...mockUseFormulas, + selectedCount: 1, + getSelectedFormulasList: vi.fn().mockReturnValue(selectedFormulas), + }); + + render(); + + const shortcutEvent = new window.KeyboardEvent('keydown', { + key: 'Enter', + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + window.dispatchEvent(shortcutEvent); + + expect(shortcutEvent.defaultPrevented).toBe(true); + expect(handlePreviewMock).toHaveBeenCalledWith(null, expect.objectContaining({ formulas: selectedFormulas })); + }); + + it('saves on Ctrl+S and prevents the browser default action', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + + render(); + + const shortcutEvent = new window.KeyboardEvent('keydown', { + key: 's', + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + window.dispatchEvent(shortcutEvent); + + expect(shortcutEvent.defaultPrevented).toBe(true); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + }); + it('can open youtube resources when class is selected', () => { const mockDataWithClass = { ...mockUseFormulas, @@ -403,4 +468,4 @@ describe('CreateCheatSheet Component', () => { expect(mockClearSelections).toHaveBeenCalled(); expect(mockReset).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/data/subjectVideos.js b/frontend/src/data/subjectVideos.js index e6da17f..96b49ba 100644 --- a/frontend/src/data/subjectVideos.js +++ b/frontend/src/data/subjectVideos.js @@ -581,6 +581,47 @@ export const CURATED_SUBJECT_VIDEOS = { }, ], + "LINEAR ALGEBRA I": [ + { + title: "Introduction to Vectors and Their Operations", + videoId: "KBSCMTYaH1s", + channel: "Professor Dave Explains", + topic: "Vector Basics" + }, + { + title: "Linear Algebra - Matrix Operations", + videoId: "p48uw2vFWQs", + channel: "Postcard Professor", + topic: "Matrix Operations" + }, + { + title: "Cramer's Rule - 2x2 Linear System", + videoId: "vXqlIOX2itM", + channel: "The Organic Chemistry Tutor", + topic: "2x2 Systems" + }, + ], + "LINEAR ALGEBRA II": [ + { + title: "Linear Algebra: Properties of Matrix Operations - Part 1 (Section 2.2) | Math with Professor V", + videoId: "pqhI8RCNkZk", + channel: "Math With Professor V", + topic: "Matrix Properties" + }, + { + title: "Eigenvectors and eigenvalues | Chapter 14, Essence of linear algebra", + videoId: "PFDu9oVAE-g", + channel: "3Blue1Brown", + topic: "Eigenvalues & Eigenvectors" + }, + { + title: "Further Matrix Decompositions: LU, Cholesky, QR, and SVD", + videoId: "wHAJzemKQW4", + channel: "Professor Dave Explains", + topic: "Decompositions & Spaces", + }, + ], + }; const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be']);