diff --git a/README.md b/README.md index 1b75a4f..d907664 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,26 @@ The main editor is a three-region workspace: - curated class/section video links with compact right-rail cards and API search kept as a per-section fallback - LaTeX editor remains closed by default so the compiled PDF stays front and center - GitHub Pages project-page assets were removed; the app is now documented as a local/Docker full-stack project +- Animated compile button with shimmer effect while compiling with a green flash on success +- Toast notification now replaces the browser alert on save +- Keyboard Shortcuts: Ctrl+Enter to compile, Ctrl+S to save, Escape to close the video modal +- Browser tab title now updates to reflect the name of the active cheat sheet +- character counter on the title input with a limit of 80 characters +- New 'last saved' timestamp displayed next to the save button +- Scroll to top button in the PDF Preview +- Empty state illustration in the right panel when no sections are selected +- Section count badge on the right panel header +- Select all/Deselect all option above the subject class list +- Clear search button for YouTube Search Results +- PDF page number display in the preview toolbar +- Focus ring styles for keyboard navigation accessibility +- Improved muted text contrast to meet the WCAG AA standards +- Custom scrollbar styling across all the panels +- Hover transitions on video cards +- Smooth transitions for panel show/hide options +- Improvements for mobile responsiveness for screens under 768px +- Divider lines between the layout option selections +- Full implementation of YouTube videos across each subject ### Editing and generation @@ -121,6 +141,17 @@ The main editor is a three-region workspace: - section-scoped “search more” behavior so the YouTube API is a last-resort fallback for the clicked section only - request validation and error handling for missing key, invalid topics, empty results, and upstream failures +### Themes +- Light Mode +- Dark Mode +- Miami Theme +- Forest Theme +- Cool Gray Theme +- Neon Theme +- Galaxy Theme +- Red Theme +- Pink 'Blossom' Inspired Theme + ## Tech stack | Layer | Technology | @@ -313,6 +344,8 @@ Services: - PHYSICS II - STATISTICS I - STATISTICS II +- LINEAR ALGEBRA I +- LINEAR ALGEBRA II Each class contains multiple categories and formulas in `backend/api/formula_data/`. diff --git a/backend/api/latex_utils.py b/backend/api/latex_utils.py index e186b59..498a560 100644 --- a/backend/api/latex_utils.py +++ b/backend/api/latex_utils.py @@ -136,7 +136,7 @@ def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spa f"% @cheatsheet-layout spacing: {spacing} | change layout options up top to update spacing", f"% @cheatsheet-layout margins: {margins} | change layout options up top to update margins", f"% @cheatsheet-layout orientation: {orientation} | change layout options up top to update orientation", - "%", + "%" ] @@ -148,12 +148,12 @@ def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing=" spacing_values = get_spacing_values(spacing, font_size) doc_class, doc_class_size = get_document_class(font_size) - # 1. Force the PDF driver to rotate by passing landscape and letterpaper to the document class + # Force the PDF driver to use letterpaper, add landscape if requested doc_options = f"{doc_class_size},fleqn,letterpaper" if orientation == "landscape": doc_options += ",landscape" - # 2. Also pass them to the geometry package + # Also pass them to the geometry package geometry_options = f"letterpaper,margin={margins}" if orientation == "landscape": geometry_options += ",landscape" diff --git a/backend/api/views.py b/backend/api/views.py index 4be731a..e987706 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -372,6 +372,7 @@ def compile_latex(request): font_size = cheatsheet.font_size margins = cheatsheet.margins spacing = cheatsheet.spacing + orientation = getattr(cheatsheet, "orientation", None) or "portrait" content = cheatsheet.build_full_latex() if not content: @@ -380,15 +381,17 @@ def compile_latex(request): content = normalize_latex_layout(content, columns, font_size, margins, spacing, orientation) if normalize_only: + layout_response = { + "columns": columns, + "font_size": font_size, + "margins": margins, + "spacing": spacing, + "orientation": orientation, + } + return Response({ "tex_code": content, - "layout": { - "columns": columns, - "font_size": font_size, - "margins": margins, - "spacing": spacing, - "orientation": orientation, - }, + "layout": layout_response, }) with tempfile.TemporaryDirectory() as tempdir: diff --git a/current-ui.png b/current-ui.png index 714c430..b92444f 100644 Binary files a/current-ui.png and b/current-ui.png differ diff --git a/frontend/src/App.css b/frontend/src/App.css index 33b6b21..928ee98 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 ========================================================================== */ @@ -522,6 +550,7 @@ label { .input-field { width: 100%; padding: 0.75rem var(--space-md); + margin-top: 0; margin-bottom: var(--space-lg); background-color: var(--input-bg); color: var(--input-text); @@ -1110,6 +1139,10 @@ label { padding: 0.95rem 0.85rem 0; } +.left-panel-title-group .input-field { + margin-top: -0.25rem; +} + /* ========================================================================== LAYOUT OPTIONS ========================================================================== */ @@ -2081,6 +2114,14 @@ left panel - footer buttons box-shadow: none; } +.btn-compile-hint { + font-size: 0.6rem; + opacity: 0.6; + margin-left: 0.4rem; + font-weight: 400; + letter-spacing: 0; +} + .btn-compile:hover:not(:disabled) { background: var(--btn-primary-hover); box-shadow: none; @@ -2620,12 +2661,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 +2693,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 +2951,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 +3208,369 @@ 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; +} + +.title-header-row { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0; +} + +.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..a235011 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…

@@ -431,6 +443,8 @@ const FormulaSelection = ({ selectedCategories, groupedFormulas, toggleClass, + selectAllClasses, + deselectAllClasses, toggleCategory, selectedCount, hasSelectedClasses, @@ -453,11 +467,27 @@ 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 +733,86 @@ 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 scrollFrameRef = useRef(null); + const cancelPendingScrollFrame = useCallback(() => { + if (scrollFrameRef.current) { + window.cancelAnimationFrame(scrollFrameRef.current); + scrollFrameRef.current = null; + } + }, []); - const clampZoom = (value) => Math.min(2, Math.max(0.5, value)); + const updateScrollState = useCallback(() => { + const scrollContainer = scrollRef.current; + if (!scrollContainer) { + return; + } + + setShowScrollTop(scrollContainer.scrollTop > 300); + + const pages = scrollContainer.querySelectorAll('.pdf-page'); + if (pages?.length) { + const containerTop = scrollContainer.getBoundingClientRect().top; + let current = 1; + pages.forEach((page, index) => { + const pageTop = page.getBoundingClientRect().top - containerTop; + if (pageTop <= 100) current = index + 1; + }); + setCurrentPage(current); + } + }, []); + + const handlePdfScroll = useCallback(() => { + if (scrollFrameRef.current) { + return; + } + + scrollFrameRef.current = window.requestAnimationFrame(() => { + scrollFrameRef.current = null; + updateScrollState(); + }); + }, [updateScrollState]); + + useEffect(() => { + return () => cancelPendingScrollFrame(); + }, [cancelPendingScrollFrame]); + + useEffect(() => { + if (!pdfBlob || compileError || !numPages) { + return; + } + updateScrollState(); + }, [numPages, updateScrollState, pdfBlob, compileError]); + + useEffect(() => { + cancelPendingScrollFrame(); + setCurrentPage(1); + setShowScrollTop(false); + }, [cancelPendingScrollFrame, pdfBlob]); + + 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 +821,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 +859,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 && (