diff --git a/README.md b/README.md index 48bd635..d0d404e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele | Frontend | React 18 + Vite | | Backend | Django 6 + Django REST Framework | | LaTeX Engine | Tectonic | -| Database | SQLite (dev) / MariaDB (prod) | +| Database | SQLite (dev) / PostgreSQL (Docker/prod) | | Container | Docker Compose | ## Project Structure @@ -141,7 +141,7 @@ The frontend will be available at `http://localhost:5173/`. docker compose up --build ``` -This builds and starts the Django backend, React frontend, and MariaDB database. +This builds and starts the Django backend, React frontend, and PostgreSQL database. ## Running Tests diff --git a/backend/api/models.py b/backend/api/models.py index a9c1d1b..500dcf2 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -31,6 +31,44 @@ class CheatSheet(models.Model): def __str__(self): return self.title + + def _build_practice_problems_section(self): + problems = list(self.problems.all()) + if not problems: + return "" + + section_lines = ["\\section*{Practice Problems}"] + for problem in problems: + section_lines.append( + f"\\textbf{{Problem {problem.order}:}} {problem.question_latex}" + ) + if problem.answer_latex: + section_lines.append(f"\\textbf{{Answer:}} {problem.answer_latex}") + section_lines.append("") + + return "\n".join(section_lines).rstrip() + + def _inject_practice_problems_into_document(self, content): + practice_problems = self._build_practice_problems_section() + if not practice_problems: + return content + + end_document = r"\end{document}" + end_multicols = r"\end{multicols}" + + insert_before = end_document + if end_multicols in content and content.rfind(end_multicols) < content.rfind(end_document): + insert_before = end_multicols + + insert_index = content.rfind(insert_before) + if insert_index == -1: + return content + + return ( + f"{content[:insert_index].rstrip()}\n\n" + f"{practice_problems}\n" + f"{content[insert_index:]}" + ) def build_full_latex(self): """ @@ -40,13 +78,14 @@ def build_full_latex(self): """ content = self.latex_content or "" - # If it's already a complete document, return as-is - if r"\begin{document}" in content: - return content + # If it's already a complete document, keep its layout and inject problems if needed + if r"\begin{document}" in content and r"\end{document}" in content: + return self._inject_practice_problems_into_document(content) # Build document header + document_class = "extarticle" if self.font_size in {"8pt", "9pt"} else "article" header = [ - "\\documentclass{article}", + f"\\documentclass{{{document_class}}}", "\\usepackage[utf8]{inputenc}", "\\usepackage{amsmath, amssymb}", f"\\usepackage[a4paper, margin={self.margins}]{{geometry}}", @@ -54,7 +93,7 @@ def build_full_latex(self): # Add font size if specified if self.font_size and self.font_size != "10pt": - header[0] = f"\\documentclass[{self.font_size}]{{article}}" + header[0] = f"\\documentclass[{self.font_size}]{{{document_class}}}" # Add multicolumn support if needed if self.columns > 1: @@ -75,15 +114,9 @@ def build_full_latex(self): # Add main content document_parts.append(content) - # Add practice problems if they exist - problems = self.problems.all() - if problems: - document_parts.append("\\section*{Practice Problems}") - for problem in problems: - document_parts.append(f"\\textbf{{Problem {problem.order}:}} {problem.question_latex}") - if problem.answer_latex: - document_parts.append(f"\\textbf{{Answer:}} {problem.answer_latex}") - document_parts.append("") # Add spacing + practice_problems = self._build_practice_problems_section() + if practice_problems: + document_parts.append(practice_problems) # Close multicolumn environment if needed if self.columns > 1: diff --git a/backend/api/tests.py b/backend/api/tests.py index ac3f6f0..ecaad92 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -95,6 +95,50 @@ def test_build_full_latex_passthrough(self): ) assert sheet.build_full_latex() == raw + def test_build_full_latex_passthrough_inserts_problems_before_document_end(self): + raw = "\\documentclass{article}\n\\begin{document}\nCustom\n\\end{document}" + sheet = CheatSheet.objects.create( + title="Raw With Problems", + latex_content=raw, + ) + PracticeProblem.objects.create( + cheat_sheet=sheet, + question_latex="Show that $x^2 \\ge 0$.", + answer_latex="Because squares are nonnegative.", + order=1, + ) + + full = sheet.build_full_latex() + + assert "Practice Problems" in full + assert "Show that $x^2 \\ge 0$." in full + assert full.index("Practice Problems") < full.index("\\end{document}") + + def test_build_full_latex_passthrough_inserts_problems_before_end_multicols(self): + raw = ( + "\\documentclass{article}\n" + "\\usepackage{multicol}\n" + "\\begin{document}\n" + "\\begin{multicols}{2}\n" + "Custom\n" + "\\end{multicols}\n" + "\\end{document}" + ) + sheet = CheatSheet.objects.create( + title="Raw Multi", + latex_content=raw, + ) + PracticeProblem.objects.create( + cheat_sheet=sheet, + question_latex="Integrate $x$.", + answer_latex="$x^2 / 2 + C$", + order=1, + ) + + full = sheet.build_full_latex() + + assert full.index("Practice Problems") < full.index("\\end{multicols}") + def test_build_full_latex_with_problems(self): sheet = CheatSheet.objects.create( title="With Problems", @@ -111,6 +155,17 @@ def test_build_full_latex_with_problems(self): assert "What is $1+1$?" in full assert "$2$" in full + def test_build_full_latex_8pt_uses_extarticle(self): + sheet = CheatSheet.objects.create( + title="Small Font", + latex_content="Content", + font_size="8pt", + ) + + full = sheet.build_full_latex() + + assert "\\documentclass[8pt]{extarticle}" in full + # ── API Tests ──────────────────────────────────────────────────────── diff --git a/backend/requirements.txt b/backend/requirements.txt index aa8992f..2589325 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ dj-database-url>=2.1 psycopg2-binary>=2.9 # Testing -pytest>=8.0 +pytest>=9.0.3 pytest-django>=4.8 # Development (linting, security) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 2d3c657..f46d991 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -3,6 +3,9 @@ import reactPlugin from 'eslint-plugin-react'; import reactHooksPlugin from 'eslint-plugin-react-hooks'; export default [ + { + ignores: ['dist/**', 'node_modules/**'], + }, js.configs.recommended, { files: ['**/*.{js,jsx}'], @@ -41,5 +44,13 @@ export default [ version: 'detect' } } + }, + { + files: ['vite.config.js'], + languageOptions: { + globals: { + process: 'readonly' + } + } } ]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e64c290..6253c04 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "eslint": "^9.0.0", "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^5.0.0", - "vite": "^6.0.0" + "vite": "^6.4.2" } }, "node_modules/@babel/code-frame": { @@ -4180,9 +4180,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -5021,9 +5021,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 8e00dfa..f3ff1c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,9 @@ "eslint": "^9.0.0", "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^5.0.0", - "vite": "^6.0.0" + "vite": "^6.4.2" + }, + "overrides": { + "picomatch": "^4.0.4" } } diff --git a/frontend/src/App.css b/frontend/src/App.css index 9a73707..96fd5b6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -16,6 +16,14 @@ --input-bg: #0f0f12; --input-border: #3f3f46; --input-text: #fafafa; + --code-command: #7dd3fc; + --code-comment: #94a3b8; + --code-brace: #fbbf24; + --code-symbol: #c084fc; + --code-error: #fca5a5; + --code-error-bg: rgba(248, 113, 113, 0.12); + --code-error-gutter: rgba(248, 113, 113, 0.18); + --code-modified-bg: rgba(59, 130, 246, 0.08); /* Button colors */ --btn-primary: #3b82f6; @@ -64,6 +72,14 @@ --input-bg: #ffffff; --input-border: #d4d4d8; --input-text: #18181b; + --code-command: #0369a1; + --code-comment: #64748b; + --code-brace: #b45309; + --code-symbol: #7c3aed; + --code-error: #b91c1c; + --code-error-bg: rgba(220, 38, 38, 0.1); + --code-error-gutter: rgba(220, 38, 38, 0.14); + --code-modified-bg: rgba(59, 130, 246, 0.08); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); @@ -254,6 +270,7 @@ label { .editor-wrapper { display: flex; + position: relative; height: 60vh; max-height: 850px; min-height: 400px; @@ -265,6 +282,11 @@ label { transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } +.editor-wrapper.has-error { + border-color: rgba(248, 113, 113, 0.45); + box-shadow: var(--shadow-inset), 0 0 0 1px rgba(248, 113, 113, 0.18); +} + .editor-wrapper:focus-within { border-color: var(--primary); box-shadow: var(--shadow-inset), 0 0 0 3px rgba(59, 130, 246, 0.1); @@ -287,18 +309,86 @@ label { line-height: 1.6; color: var(--text-muted); height: calc(13px * 1.6); + padding-right: 2px; } -.textarea-field { +.line-number.error { + color: var(--code-error); + background: var(--code-error-gutter); + font-weight: 600; + border-radius: 4px; +} + +.editor-surface { + position: relative; flex: 1; + overflow: hidden; +} + +.editor-highlight-layer, +.textarea-field { + position: absolute; + inset: 0; width: 100%; height: 100%; padding: var(--space-md); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 13px; line-height: 1.6; - background-color: var(--input-bg); + white-space: pre; + tab-size: 2; + overflow: auto; +} + +.editor-highlight-layer { + margin: 0; color: var(--input-text); + background: transparent; + pointer-events: none; + scrollbar-width: none; +} + +.editor-highlight-layer::-webkit-scrollbar { + display: none; +} + +.editor-highlight-layer.modified { + background: linear-gradient(180deg, var(--code-modified-bg) 0%, transparent 22%); +} + +.editor-highlight-line { + min-height: calc(13px * 1.6); + color: var(--input-text); +} + +.editor-highlight-line.error { + background: linear-gradient(90deg, var(--code-error-bg) 0%, transparent 85%); + border-left: 2px solid var(--code-error); + padding-left: calc(var(--space-sm) - 2px); +} + +.latex-token.command { + color: var(--code-command); +} + +.latex-token.comment { + color: var(--code-comment); +} + +.latex-token.brace { + color: var(--code-brace); +} + +.latex-token.symbol { + color: var(--code-symbol); +} + +.textarea-field { + z-index: 1; + background-color: transparent; + color: transparent; + caret-color: var(--input-text); + -webkit-text-fill-color: transparent; border: none; border-radius: 0; resize: none; @@ -306,7 +396,16 @@ label { } .textarea-field.modified { - color: #ffffff; + caret-color: var(--primary); +} + +.textarea-field::selection { + background: rgba(59, 130, 246, 0.28); +} + +.textarea-field::placeholder { + color: var(--text-muted); + -webkit-text-fill-color: var(--text-muted); } .textarea-field:focus { @@ -315,6 +414,26 @@ label { box-shadow: var(--shadow-inset), 0 0 0 3px rgba(59, 130, 246, 0.1); } +.editor-status { + margin-bottom: var(--space-sm); + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 500; +} + +.editor-status.modified { + color: var(--primary); + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.18); +} + +.editor-status.error { + color: var(--code-error); + background: var(--code-error-bg); + border: 1px solid rgba(248, 113, 113, 0.24); +} + .layout-select { padding: 0.5rem 0.75rem; border-radius: var(--radius-md); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e598907..6061728 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,16 @@ import { useState, useEffect } from 'react' import './App.css' import CreateCheatSheet from './components/CreateCheatSheet'; +const DEFAULT_SHEET = { + title: '', + content: '', + columns: 2, + fontSize: '10pt', + spacing: 'large', + margins: '0.25in', + selectedFormulas: [], +}; + function App() { const normalizeTheme = (value) => { return value === 'dark' || value === 'light' ? value : 'dark'; @@ -16,8 +26,9 @@ function App() { console.error("Failed to parse sheet", e); } } - return { title: '', content: '', columns: 2, fontSize: '10pt', spacing: 'large' }; + return DEFAULT_SHEET; }); + const [isSaving, setIsSaving] = useState(false); const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('theme'); return normalizeTheme(saved); @@ -32,6 +43,11 @@ function App() { setTheme(prev => prev === 'dark' ? 'light' : 'dark'); }; + const handleReset = () => { + setCheatSheet(DEFAULT_SHEET); + localStorage.setItem('currentCheatSheet', JSON.stringify(DEFAULT_SHEET)); + }; + useEffect(() => { const savedSheet = localStorage.getItem('currentCheatSheet'); if (savedSheet) { @@ -43,11 +59,61 @@ function App() { } }, []); - const handleSave = (data, showFeedback = true) => { - setCheatSheet(data); - localStorage.setItem('currentCheatSheet', JSON.stringify(data)); - if (showFeedback) { + const handleSave = async (data, showFeedback = true) => { + const nextSheet = { + ...cheatSheet, + ...data, + selectedFormulas: data.selectedFormulas ?? cheatSheet.selectedFormulas ?? [], + }; + + setCheatSheet(nextSheet); + localStorage.setItem('currentCheatSheet', JSON.stringify(nextSheet)); + + if (!showFeedback) { + return nextSheet; + } + + setIsSaving(true); + + try { + const sheetId = nextSheet.id; + const response = await fetch(sheetId ? `/api/cheatsheets/${sheetId}/` : '/api/cheatsheets/', { + method: sheetId ? 'PATCH' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: nextSheet.title, + latex_content: nextSheet.content, + columns: nextSheet.columns, + margins: nextSheet.margins, + font_size: nextSheet.fontSize, + selected_formulas: nextSheet.selectedFormulas, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || errorData.error || 'Failed to save cheat sheet'); + } + + const savedSheet = await response.json(); + const persistedSheet = { + ...nextSheet, + id: savedSheet.id, + content: savedSheet.latex_content ?? nextSheet.content, + fontSize: savedSheet.font_size ?? nextSheet.fontSize, + selectedFormulas: savedSheet.selected_formulas ?? nextSheet.selectedFormulas, + }; + + setCheatSheet(persistedSheet); + localStorage.setItem('currentCheatSheet', JSON.stringify(persistedSheet)); alert('Progress saved!'); + return persistedSheet; + } catch (error) { + console.error('Failed to save cheat sheet', error); + alert(`Failed to save progress: ${error.message}`); + throw error; + } finally { + setIsSaving(false); } }; @@ -68,6 +134,8 @@ function App() { {}} /> @@ -82,4 +150,4 @@ function App() { ); } -export default App \ No newline at end of file +export default App diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx index 771eab6..87fb007 100644 --- a/frontend/src/components/CreateCheatSheet.jsx +++ b/frontend/src/components/CreateCheatSheet.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -313,38 +313,128 @@ const FormulaSelection = ({ ); -const LatexEditor = ({ content, onChange, isModified }) => { +const COMPILE_ERROR_LINE_REGEX = /document\.tex:(\d+):/g; + +const escapeHtml = (value = '') => value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const highlightLatexLine = (line = '') => { + const placeholders = []; + const stashToken = (html) => { + const placeholder = `LATEXTOKEN${placeholders.length}PLACEHOLDER`; + placeholders.push(html); + return placeholder; + }; + + let html = escapeHtml(line); + + html = html.replace(/%.*$/g, (match) => stashToken(`${match}`)); + html = html.replace(/\\[a-zA-Z@]+|\\./g, (match) => stashToken(`${match}`)); + html = html.replace(/[{}[\]]/g, (match) => `${match}`); + html = html.replace(/[$&#_^]/g, (match) => `${match}`); + + html = html.replace(/LATEXTOKEN(\d+)PLACEHOLDER/g, (_, index) => placeholders[Number(index)] ?? ''); + + return html || ' '; +}; + +const extractCompileErrorLines = (compileError = '') => { + const normalizedError = compileError || ''; + const lineNumbers = new Set(); + + for (const match of normalizedError.matchAll(COMPILE_ERROR_LINE_REGEX)) { + lineNumbers.add(Number(match[1])); + } + + return lineNumbers; +}; + +const getCompileErrorSummary = (compileError = '') => { + const normalizedError = compileError || ''; + + if (!normalizedError) { + return ''; + } + + const lineMatch = normalizedError.match(/document\.tex:(\d+):\s*([^\n]+)/); + if (lineMatch) { + return `Line ${lineMatch[1]}: ${lineMatch[2].replace(/^error:\s*/i, '').trim()}`; + } + + return (normalizedError.split('\n').find((line) => line.trim()) || '').replace(/^error:\s*/i, '').trim(); +}; + +const LatexEditor = ({ content, onChange, isModified, compileError }) => { const textareaRef = useRef(null); const lineNumbersRef = useRef(null); + const highlightLayerRef = useRef(null); + + const errorLines = useMemo(() => extractCompileErrorLines(compileError), [compileError]); + const compileErrorSummary = useMemo(() => getCompileErrorSummary(compileError), [compileError]); + const highlightedLines = useMemo(() => { + const lines = content ? content.split('\n') : ['']; - const lineCount = content ? content.split('\n').length : 1; + return lines.map((line, index) => ({ + lineNumber: index + 1, + highlightedHtml: highlightLatexLine(line), + hasError: errorLines.has(index + 1), + })); + }, [content, errorLines]); + + const lineCount = highlightedLines.length; const handleScroll = () => { if (lineNumbersRef.current && textareaRef.current) { lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop; } + + if (highlightLayerRef.current && textareaRef.current) { + highlightLayerRef.current.scrollTop = textareaRef.current.scrollTop; + highlightLayerRef.current.scrollLeft = textareaRef.current.scrollLeft; + } }; return (
-
+ {compileErrorSummary ? ( +
{compileErrorSummary}
+ ) : isModified ? ( +
Manual edits ready to recompile
+ ) : null} +
{Array.from({ length: lineCount }, (_, i) => ( -
{i + 1}
+
{i + 1}
))}
-