diff --git a/backend/api/formula_data/__init__.py b/backend/api/formula_data/__init__.py index c008519..5fc8406 100644 --- a/backend/api/formula_data/__init__.py +++ b/backend/api/formula_data/__init__.py @@ -17,6 +17,8 @@ from .physics_ii import FORMULAS as PHYSICS_II, CLASS_NAME as PHYSICS_II_NAME from .statistics_i import FORMULAS as STATISTICS_I, CLASS_NAME as STATISTICS_I_NAME from .statistics_ii import FORMULAS as STATISTICS_II, CLASS_NAME as STATISTICS_II_NAME +from .linear_algebra_i import FORMULAS as LINEAR_ALGEBRA_I, CLASS_NAME as LINEAR_ALGEBRA_I_NAME +from .linear_algebra_ii import FORMULAS as LINEAR_ALGEBRA_II, CLASS_NAME as LINEAR_ALGEBRA_II_NAME AVAILABLE_CLASSES = [ PRE_ALGEBRA_NAME, @@ -33,6 +35,8 @@ PHYSICS_II_NAME, STATISTICS_I_NAME, STATISTICS_II_NAME, + LINEAR_ALGEBRA_I_NAME, + LINEAR_ALGEBRA_II_NAME, ] # Classes that have categories (normal structure) @@ -50,6 +54,8 @@ PHYSICS_II_NAME, STATISTICS_I_NAME, STATISTICS_II_NAME, + LINEAR_ALGEBRA_I_NAME, + LINEAR_ALGEBRA_II_NAME, ] # Special classes with no categories (single toggle) @@ -71,6 +77,8 @@ PHYSICS_II_NAME: PHYSICS_II, STATISTICS_I_NAME: STATISTICS_I, STATISTICS_II_NAME: STATISTICS_II, + LINEAR_ALGEBRA_I_NAME: LINEAR_ALGEBRA_I, + LINEAR_ALGEBRA_II_NAME: LINEAR_ALGEBRA_II, } diff --git a/backend/api/formula_data/linear_algebra_i.py b/backend/api/formula_data/linear_algebra_i.py new file mode 100644 index 0000000..9ae39be --- /dev/null +++ b/backend/api/formula_data/linear_algebra_i.py @@ -0,0 +1,20 @@ +""" LINEAR ALGEBRA I formulas """ +CLASS_NAME = "LINEAR ALGEBRA I" + +FORMULAS = { + "Vector Basics": [ + {"name": "Vector Addition", "latex": r"\mathbf{u} + \mathbf{v} = \langle u_1+v_1, u_2+v_2 \rangle"}, + {"name": "Scalar Multiplication", "latex": r"k\mathbf{u} = \langle ku_1, ku_2 \rangle"}, + {"name": "Magnitude (L2 Norm)", "latex": r"\|\mathbf{v}\| = \sqrt{v_1^2 + v_2^2 + \dots + v_n^2}"}, + {"name": "Dot Product", "latex": r"\mathbf{u} \cdot \mathbf{v} = u_1v_1 + u_2v_2 + \dots + u_nv_n"}, + ], + "Matrix Operations": [ + {"name": "Matrix Addition", "latex": r"A + B = [a_{ij} + b_{ij}]"}, + {"name": "Matrix-Vector Multiplication", "latex": r"A\mathbf{x} = \mathbf{b}"}, + {"name": "Transpose", "latex": r"(A^T)_{ij} = A_{ji}"}, + ], + "2x2 Systems": [ + {"name": "2x2 Determinant", "latex": r"\det(A) = ad - bc"}, + {"name": "2x2 Inverse", "latex": r"A^{-1} = \frac{1}{ad-bc} \begin{bmatrix} d & -b \\ -c & a \end{bmatrix}"}, + ] +} \ No newline at end of file diff --git a/backend/api/formula_data/linear_algebra_ii.py b/backend/api/formula_data/linear_algebra_ii.py new file mode 100644 index 0000000..add5433 --- /dev/null +++ b/backend/api/formula_data/linear_algebra_ii.py @@ -0,0 +1,19 @@ +""" LINEAR ALGEBRA II formulas """ +CLASS_NAME = "LINEAR ALGEBRA II" + +FORMULAS = { + "Matrix Properties": [ + {"name": "Invertibility Condition", "latex": r"A \text{ is invertible iff } \det(A) \neq 0"}, + {"name": "Matrix Multiplication Property", "latex": r"(AB)^{-1} = B^{-1}A^{-1}"}, + {"name": "Orthogonality", "latex": r"Q^T Q = I"}, + ], + "Eigenvalues & Eigenvectors": [ + {"name": "Characteristic Equation", "latex": r"\det(A - \lambda I) = 0"}, + {"name": "Eigenvector Definition", "latex": r"A\mathbf{v} = \lambda\mathbf{v}"}, + ], + "Decompositions & Spaces": [ + {"name": "Diagonalization", "latex": r"A = PDP^{-1}"}, + {"name": "Rank-Nullity Theorem", "latex": r"\text{rank}(A) + \text{nullity}(A) = n"}, + {"name": "Projection Formula", "latex": r"\text{proj}_{\mathbf{u}}(\mathbf{v}) = \frac{\mathbf{u} \cdot \mathbf{v}}{\|\mathbf{u}\|^2}\mathbf{u}"}, + ] +} \ No newline at end of file diff --git a/backend/api/latex_utils.py b/backend/api/latex_utils.py index 802361a..e186b59 100644 --- a/backend/api/latex_utils.py +++ b/backend/api/latex_utils.py @@ -30,7 +30,6 @@ "large": ("1.2pt", "1.2pt"), } - FONT_SIZE_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)pt$") SPACING_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)pt$") BODY_FONT_COMMAND_PATTERN = re.compile( @@ -47,7 +46,7 @@ LEGACY_ANSWER_LABEL_PATTERN = re.compile(r"\\textbf\{Answer:\}\s*") APP_LAYOUT_COMMENT_LINE_PATTERN = re.compile(r"(?m)^% @cheatsheet-layout .*\n?") APP_LAYOUT_COMMENT_BLOCK_PATTERN = re.compile( - r"(?m)(?:^% @cheatsheet-layout .*\n){4}^%\n?" + r"(?m)(?:^% @cheatsheet-layout .*\n)+^%\n?" ) @@ -130,31 +129,42 @@ def append_text_heading(lines, text): lines.append(r"\noindent " + text + r"\par") -def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spacing="small"): +def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"): return [ f"% @cheatsheet-layout columns: {columns} | change layout options up top to update columns", f"% @cheatsheet-layout font_size: {font_size} | change layout options up top to update text size", 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", "%", ] -def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small"): +def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"): """ Build a dynamic LaTeX header based on user-selected options. """ size_command = get_body_font_command(font_size) 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 + doc_options = f"{doc_class_size},fleqn,letterpaper" + if orientation == "landscape": + doc_options += ",landscape" + + # 2. Also pass them to the geometry package + geometry_options = f"letterpaper,margin={margins}" + if orientation == "landscape": + geometry_options += ",landscape" header_lines = [ - f"\\documentclass[{doc_class_size},fleqn]{{{doc_class}}}", - f"\\usepackage[margin={margins}]{{geometry}}", + f"\\documentclass[{doc_options}]{{{doc_class}}}", + f"\\usepackage[{geometry_options}]{{geometry}}", "\\usepackage{amsmath, amssymb}", "\\usepackage{enumitem}", "\\usepackage{multicol}", - "\\usepackage{adjustbox}", # For auto-scaling equations to fit column width + "\\usepackage{adjustbox}", "", "\\setlength{\\mathindent}{0pt}", "\\setlist[itemize]{noitemsep, topsep=0pt, leftmargin=*}", @@ -188,12 +198,12 @@ def build_dynamic_footer(columns=2): return "\n".join(footer_lines) -def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in", spacing="small"): +def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"): """Rebuild document wrappers so current layout controls apply to existing LaTeX content.""" if not content: return content - header = build_dynamic_header(columns, font_size, margins, spacing) + header = build_dynamic_header(columns, font_size, margins, spacing, orientation) footer = build_dynamic_footer(columns) if r"\begin{document}" not in content or r"\end{document}" not in content: @@ -219,18 +229,18 @@ def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in" body = re.sub(r"(?m)^\\vspace\{[^}]+\}\s*$", rf"\\vspace{{{formula_gap}}}", body) body = body.strip("\n") - layout_comment_block = "\n".join(build_layout_comment_block(columns, font_size, margins, spacing)) + layout_comment_block = "\n".join(build_layout_comment_block(columns, font_size, margins, spacing, orientation)) body = layout_comment_block + ("\n" + body if body else "") return header + body + ("\n" if body else "") + footer -def build_latex_for_formulas(selected_formulas, columns=4, font_size="9pt", margins="0.15in", spacing="small"): +def build_latex_for_formulas(selected_formulas, columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"): """ Given a list of selected formulas (each with class_name, category, name, latex), build a complete LaTeX document. """ - header = build_dynamic_header(columns, font_size, margins, spacing) + header = build_dynamic_header(columns, font_size, margins, spacing, orientation) footer = build_dynamic_footer(columns) formula_gap = get_spacing_values(spacing, font_size)["formula_gap"] @@ -238,7 +248,7 @@ def build_latex_for_formulas(selected_formulas, columns=4, font_size="9pt", marg return header + footer body_lines = [] - body_lines.extend(build_layout_comment_block(columns, font_size, margins, spacing)) + body_lines.extend(build_layout_comment_block(columns, font_size, margins, spacing, orientation)) current_class = None current_category = None in_flushleft = False @@ -334,4 +344,4 @@ def compile_latex_to_pdf(content): # Read and return the PDF bytes before the temporary directory is removed with open(pdf_file_path, "rb") as pdf_file: - return pdf_file.read() + return pdf_file.read() \ No newline at end of file diff --git a/backend/api/migrations/0008_cheatsheet_orientation.py b/backend/api/migrations/0008_cheatsheet_orientation.py new file mode 100644 index 0000000..0f392d9 --- /dev/null +++ b/backend/api/migrations/0008_cheatsheet_orientation.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-05-06 02:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_cheatsheet_content_source'), + ] + + operations = [ + migrations.AddField( + model_name='cheatsheet', + name='orientation', + field=models.CharField(default='portrait', max_length=20), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index a16dea9..19ef163 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -39,6 +39,7 @@ class CheatSheet(models.Model): margins = models.CharField(max_length=20, default="0.15in") font_size = models.CharField(max_length=10, default="9pt") spacing = models.CharField(max_length=10, default="small") + orientation = models.CharField(max_length=20, default="portrait") # Stores selected formulas with user-defined order: [{"class": "...", "category": "...", "name": "..."}] selected_formulas = models.JSONField(default=list, blank=True) created_at = models.DateTimeField(auto_now_add=True) @@ -100,12 +101,21 @@ def build_full_latex(self): # Build document header document_class, document_class_size = get_document_class(self.font_size) spacing_values = get_spacing_values(self.spacing, self.font_size) + + doc_options = f"{document_class_size},fleqn,letterpaper" + if self.orientation == "landscape": + doc_options += ",landscape" + + geometry_options = f"letterpaper,margin={self.margins}" + if self.orientation == "landscape": + geometry_options += ",landscape" + header = [ - f"\\documentclass[{document_class_size}]{{{document_class}}}", + f"\\documentclass[{doc_options}]{{{document_class}}}", "\\usepackage[utf8]{inputenc}", "\\usepackage{amsmath, amssymb}", "\\usepackage{adjustbox}", - f"\\usepackage[a4paper, margin={self.margins}]{{geometry}}", + f"\\usepackage[{geometry_options}]{{geometry}}", f"\\setlength{{\\baselineskip}}{{{spacing_values['baseline_skip']}}}", f"\\setlength{{\\parskip}}{{{spacing_values['paragraph_skip']}}}", ] diff --git a/backend/api/serializers.py b/backend/api/serializers.py index edd0acd..663440a 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -83,6 +83,7 @@ class Meta: "columns", "font_size", "spacing", + "orientation", "selected_formulas", "problems", "full_latex", diff --git a/backend/api/tests.py b/backend/api/tests.py index 0599af9..a5f2532 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -91,6 +91,7 @@ def test_build_full_latex_wraps_content(self): columns=1, font_size="10pt", user=self.user, + orientation="portrait", ) full = sheet.build_full_latex() assert "\\begin{document}" in full @@ -192,7 +193,7 @@ def test_build_full_latex_8pt_uses_extarticle(self): full = sheet.build_full_latex() - assert "\\documentclass[8pt]{extarticle}" in full + assert "\\documentclass[8pt,fleqn,letterpaper]{extarticle}" in full def test_build_full_latex_custom_font_size_uses_supported_wrapper(self): sheet = CheatSheet.objects.create( @@ -204,7 +205,7 @@ def test_build_full_latex_custom_font_size_uses_supported_wrapper(self): full = sheet.build_full_latex() - assert "\\documentclass[10pt]{article}" in full + assert "\\documentclass[10pt,fleqn,letterpaper]{article}" in full assert "\\fontsize{10.5pt}{11.3pt}\\selectfont" in full def test_build_full_latex_includes_saved_spacing(self): @@ -237,9 +238,9 @@ def test_normalize_latex_layout_rewraps_existing_document_with_current_settings( "\\end{document}" ) - normalized = normalize_latex_layout(raw, columns=4, font_size="8pt", margins="0.5in", spacing="tiny") + normalized = normalize_latex_layout(raw, columns=4, font_size="8pt", margins="0.5in", spacing="tiny", orientation="portrait") - assert "\\documentclass[8pt,fleqn]{extarticle}" in normalized + assert "\\documentclass[8pt,fleqn,letterpaper]{extarticle}" in normalized assert "margin=0.5in" in normalized assert "\\begin{multicols}{4}" in normalized assert "\\begin{multicols}{2}" not in normalized @@ -383,12 +384,18 @@ def test_build_dynamic_header_keeps_headers_close_to_body_size(self): def test_build_dynamic_header_accepts_custom_font_and_spacing(self): header = build_dynamic_header(columns=5, font_size="10.5pt", margins="0.25in", spacing="0.6pt") - assert "\\documentclass[10pt,fleqn]{article}" in header + assert "\\documentclass[10pt,fleqn,letterpaper]{article}" in header assert "\\fontsize{10.5pt}{11.3pt}\\selectfont" in header assert "\\setlength{\\baselineskip}{11.1pt}" in header assert "\\setlength{\\parskip}{0.6pt}" in header assert "\\begin{multicols}{5}" in header + def test_build_dynamic_header_landscape_includes_landscape_options(self): + header = build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="landscape") + assert "landscape" in header + assert "\\documentclass[9pt,fleqn,letterpaper,landscape]{extarticle}" in header + assert "letterpaper,margin=0.15in,landscape" in header + def test_build_latex_for_formulas_includes_editable_layout_comments(self): tex = build_latex_for_formulas( [{"class_name": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula", "latex": "m=\\frac{y_2-y_1}{x_2-x_1}"}], @@ -402,6 +409,7 @@ def test_build_latex_for_formulas_includes_editable_layout_comments(self): assert "% @cheatsheet-layout font_size: 10.5pt | change layout options up top to update text size" in tex assert "% @cheatsheet-layout spacing: 0.6pt | change layout options up top to update spacing" in tex assert "% @cheatsheet-layout margins: 0.5in | change layout options up top to update margins" in tex + assert "% @cheatsheet-layout orientation: portrait | change layout options up top to update orientation" in tex # ── API Tests ──────────────────────────────────────────────────────── @@ -1246,7 +1254,7 @@ def test_generate_sheet_8pt_uses_extarticle(self, auth_client): ) assert resp.status_code == 200 tex = resp.json()["tex_code"] - assert "\\documentclass[8pt,fleqn]{extarticle}" in tex + assert "\\documentclass[8pt,fleqn,letterpaper]{extarticle}" in tex assert "\\documentclass[8pt,fleqn]{article}" not in tex def test_generate_sheet_9pt_uses_extarticle(self, auth_client): @@ -1261,7 +1269,7 @@ def test_generate_sheet_9pt_uses_extarticle(self, auth_client): ) assert resp.status_code == 200 tex = resp.json()["tex_code"] - assert "\\documentclass[9pt,fleqn]{extarticle}" in tex + assert "\\documentclass[9pt,fleqn,letterpaper]{extarticle}" in tex assert "\\documentclass[9pt,fleqn]{article}" not in tex def test_generate_sheet_10pt_uses_article(self, auth_client): @@ -1276,7 +1284,7 @@ def test_generate_sheet_10pt_uses_article(self, auth_client): ) assert resp.status_code == 200 tex = resp.json()["tex_code"] - assert "\\documentclass[10pt,fleqn]{article}" in tex + assert "\\documentclass[10pt,fleqn,letterpaper]{article}" in tex assert "extarticle" not in tex def test_generate_sheet_latex_injection_blocked(self, auth_client): @@ -1375,6 +1383,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se "font_size": "8pt", "spacing": "tiny", "margins": "0.25in", + "orientation": "portrait", }, format="json", ) @@ -1387,6 +1396,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se "font_size": "8pt", "spacing": "tiny", "margins": "0.25in", + "orientation": "portrait", } assert "\\begin{multicols}{2}" in tex assert "\\fontsize{8pt}{8.8pt}\\selectfont" in tex @@ -1395,6 +1405,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se assert "% @cheatsheet-layout font_size: 8pt | change layout options up top to update text size" in tex assert "% @cheatsheet-layout spacing: tiny | change layout options up top to update spacing" in tex assert "% @cheatsheet-layout margins: 0.25in | change layout options up top to update margins" in tex + assert "orientation: portrait" in tex # ── Auth Endpoint Tests ────────────────────────────────────────────── diff --git a/backend/api/views.py b/backend/api/views.py index 3f9b657..4be731a 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -37,12 +37,12 @@ VALID_FONT_SIZES = {"8pt", "9pt", "10pt", "11pt", "12pt"} VALID_SPACING = {"tiny", "small", "medium", "large"} VALID_MARGINS = {"0.15in", "0.25in", "0.5in", "0.75in", "1in", "1.5in", "2in"} +VALID_ORIENTATION = {"portrait", "landscape"} DEFAULT_COLUMNS = 4 DEFAULT_FONT_SIZE = "9pt" DEFAULT_SPACING = "small" DEFAULT_MARGINS = "0.15in" - def is_valid_custom_pt(value, min_value, max_value): normalized = str(value or "").strip() if not normalized.endswith("pt"): @@ -61,7 +61,7 @@ def is_truthy(value): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value) -def validate_layout_params(columns, font_size, margins, spacing): +def validate_layout_params(columns, font_size, margins, spacing, orientation="portrait"): try: columns = max(1, min(5, int(columns))) except (TypeError, ValueError): @@ -75,8 +75,11 @@ def validate_layout_params(columns, font_size, margins, spacing): if spacing not in VALID_SPACING and not is_valid_custom_pt(spacing, 0, 6): spacing = DEFAULT_SPACING + + if orientation not in VALID_ORIENTATION: + orientation = "portrait" - return columns, font_size, margins, spacing + return columns, font_size, margins, spacing, orientation def build_youtube_search_query(class_name, category_name): @@ -280,8 +283,7 @@ def get_classes(request): @api_view(["POST"]) def generate_sheet(request): """ - POST /api/generate-sheet/ - Accepts { "formulas": [...], "columns": 4, "font_size": "9pt", "margins": "0.15in", "spacing": "small" } + Accepts { "formulas": [...], "columns": 4, "font_size": "9pt", "margins": "0.15in", "spacing": "small", "orientation": "portrait" } Each formula: { "class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula" } Or for special classes (like UNIT CIRCLE): { "class": "UNIT CIRCLE", "name": "Unit Circle (Key Angles)" } Returns { "tex_code": "..." } @@ -291,11 +293,12 @@ def generate_sheet(request): font_size = request.data.get("font_size", DEFAULT_FONT_SIZE) margins = request.data.get("margins", DEFAULT_MARGINS) spacing = request.data.get("spacing", DEFAULT_SPACING) + orientation = request.data.get("orientation", "portrait") - columns, font_size, margins, spacing = validate_layout_params(columns, font_size, margins, spacing) + columns, font_size, margins, spacing, orientation = validate_layout_params(columns, font_size, margins, spacing, orientation) if not selected: - tex_code = build_latex_for_formulas([], columns, font_size, margins, spacing) + tex_code = build_latex_for_formulas([], columns, font_size, margins, spacing, orientation) return Response({"tex_code": tex_code}) formula_data = get_formula_data() @@ -342,7 +345,7 @@ def generate_sheet(request): if not selected_formulas: return Response({"error": "No valid formulas found"}, status=400) - tex_code = build_latex_for_formulas(selected_formulas, columns, font_size, margins, spacing) + tex_code = build_latex_for_formulas(selected_formulas, columns, font_size, margins, spacing, orientation) return Response({"tex_code": tex_code}) @@ -351,10 +354,6 @@ def generate_sheet(request): def compile_latex(request): """ POST /api/compile/ - Accepts either: - - { "content": "...full LaTeX code..." } - - { "cheat_sheet_id": 123 } - Compiles with Tectonic on the backend and returns the PDF. """ content = request.data.get("content", "") cheat_sheet_id = request.data.get("cheat_sheet_id") @@ -363,9 +362,10 @@ def compile_latex(request): font_size = request.data.get("font_size", DEFAULT_FONT_SIZE) margins = request.data.get("margins", DEFAULT_MARGINS) spacing = request.data.get("spacing", DEFAULT_SPACING) - columns, font_size, margins, spacing = validate_layout_params(columns, font_size, margins, spacing) + orientation = request.data.get("orientation", "portrait") + + columns, font_size, margins, spacing, orientation = validate_layout_params(columns, font_size, margins, spacing, orientation) - # If cheat_sheet_id is provided, get content from the cheat sheet if cheat_sheet_id: cheatsheet = get_object_or_404(CheatSheet, pk=cheat_sheet_id, user=request.user) columns = cheatsheet.columns @@ -377,7 +377,7 @@ def compile_latex(request): if not content: return Response({"error": "No LaTeX content provided"}, status=400) - content = normalize_latex_layout(content, columns, font_size, margins, spacing) + content = normalize_latex_layout(content, columns, font_size, margins, spacing, orientation) if normalize_only: return Response({ @@ -387,6 +387,7 @@ def compile_latex(request): "font_size": font_size, "margins": margins, "spacing": spacing, + "orientation": orientation, }, }) @@ -501,10 +502,6 @@ def youtube_resources(request): # ------------------------------------------------------------------ class TemplateViewSet(viewsets.ModelViewSet): - """ - CRUD API for Templates - Get/Post/Put/Delete /api/templates/ - """ queryset = Template.objects.all() serializer_class = TemplateSerializer @@ -517,10 +514,6 @@ def get_queryset(self): class CheatSheetViewSet(viewsets.ModelViewSet): - """ - CRUD API for CheatSheets - Get/Post/Put/Delete /api/cheatsheets/ - """ queryset = CheatSheet.objects.all() serializer_class = CheatSheetSerializer permission_classes = [IsAuthenticated] @@ -533,10 +526,6 @@ def perform_create(self, serializer): @action(detail=False, methods=['post'], url_path='from-template') def from_template(self, request): - """ - POST /api/cheatsheets/from-template/ - Create cheat sheet from template - """ template_id = request.data.get("template_id") title = request.data.get("title", "Untitled") @@ -559,10 +548,6 @@ def from_template(self, request): class PracticeProblemViewSet(viewsets.ModelViewSet): - """ - CRUD API for Practice Problems - Get/Post/Put/Delete /api/problems/ - """ queryset = PracticeProblem.objects.all() serializer_class = PracticeProblemSerializer @@ -571,4 +556,4 @@ def get_queryset(self): cheat_sheet_id = self.request.query_params.get('cheat_sheet') if cheat_sheet_id: queryset = queryset.filter(cheat_sheet=cheat_sheet_id) - return queryset + return queryset \ No newline at end of file diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx index 1d51c53..7d8ea4e 100644 --- a/frontend/src/components/CreateCheatSheet.jsx +++ b/frontend/src/components/CreateCheatSheet.jsx @@ -885,7 +885,7 @@ const SnapshotTray = ({ snapshots, onRestore }) => { const FONT_SIZE_PRESETS = ['8pt', '9pt', '10pt', '11pt', '12pt']; const SPACING_PRESETS = ['tiny', 'small', 'medium', 'large']; -const LayoutOptions = ({ columns, setColumns, fontSize, setFontSize, spacing, setSpacing, margins, setMargins }) => { +const LayoutOptions = ({ columns, setColumns, fontSize, setFontSize, spacing, setSpacing, margins, setMargins, orientation, setOrientation }) => { const fontSizeMode = FONT_SIZE_PRESETS.includes(fontSize) ? fontSize : 'custom'; const spacingMode = SPACING_PRESETS.includes(spacing) ? spacing : 'custom'; @@ -973,6 +973,20 @@ const LayoutOptions = ({ columns, setColumns, fontSize, setFontSize, spacing, se + {/* NEW ORIENTATION CONTROL */} +
+ + +
+ {/* END NEW CONTROL */} ); @@ -1013,6 +1027,8 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS setSpacing, margins, setMargins, + orientation, + setOrientation, pdfBlob, isCompiling, compileError, @@ -1203,6 +1219,7 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS fontSize, spacing, margins, + orientation, selectedFormulas: getSelectedFormulasList(), compileSnapshot: { title, @@ -1212,13 +1229,14 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS fontSize, spacing, margins, + orientation, selectedFormulas: getSelectedFormulasList(), compiledAt: new Date().toISOString(), }, }, false).catch((error) => { console.error('Failed to autosave compiled sheet', error); }); - }, [columns, compileError, content, contentSource, fontSize, getSelectedFormulasList, margins, onSave, pdfBlob, spacing, title]); + }, [columns, compileError, content, contentSource, fontSize, getSelectedFormulasList, margins, onSave, orientation, pdfBlob, spacing, title]); const startResize = useCallback((panel) => (event) => { event.preventDefault(); @@ -1337,6 +1355,7 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS fontSize, spacing, margins, + orientation, selectedFormulas: getSelectedFormulasList(), }); }; @@ -1398,6 +1417,8 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS setSpacing={setSpacing} margins={margins} setMargins={setMargins} + orientation={orientation} + setOrientation={setOrientation} /> diff --git a/frontend/src/hooks/latex.js b/frontend/src/hooks/latex.js index f5df0ea..9b1f13e 100644 --- a/frontend/src/hooks/latex.js +++ b/frontend/src/hooks/latex.js @@ -10,6 +10,7 @@ const DEFAULT_LAYOUT = { fontSize: '9pt', spacing: 'small', margins: '0.15in', + orientation: 'portrait', }; function getInitialContentSource(data) { @@ -84,6 +85,7 @@ export function useLatex(initialData) { const [fontSize, setFontSize] = useState(initialData?.fontSize ?? DEFAULT_LAYOUT.fontSize); const [spacing, setSpacing] = useState(initialData?.spacing ?? DEFAULT_LAYOUT.spacing); const [margins, setMargins] = useState(initialData?.margins ?? DEFAULT_LAYOUT.margins); + const [orientation, setOrientation] = useState(initialData?.orientation ?? DEFAULT_LAYOUT.orientation); const [pdfBlob, setPdfBlob] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isCompiling, setIsCompiling] = useState(false); @@ -103,6 +105,7 @@ export function useLatex(initialData) { fontSize: initialData?.fontSize ?? DEFAULT_LAYOUT.fontSize, spacing: initialData?.spacing ?? DEFAULT_LAYOUT.spacing, margins: initialData?.margins ?? DEFAULT_LAYOUT.margins, + orientation: initialData?.orientation ?? DEFAULT_LAYOUT.orientation, }); // Revoke the object URL when the component unmounts to prevent memory leaks @@ -172,11 +175,13 @@ export function useLatex(initialData) { setFontSize(saved.fontSize ?? DEFAULT_LAYOUT.fontSize); setSpacing(saved.spacing ?? DEFAULT_LAYOUT.spacing); setMargins(saved.margins ?? DEFAULT_LAYOUT.margins); + setOrientation(saved.orientation ?? DEFAULT_LAYOUT.orientation); lastCompiledLayoutRef.current = { columns: saved.columns ?? DEFAULT_LAYOUT.columns, fontSize: saved.fontSize ?? DEFAULT_LAYOUT.fontSize, spacing: saved.spacing ?? DEFAULT_LAYOUT.spacing, margins: saved.margins ?? DEFAULT_LAYOUT.margins, + orientation: saved.orientation ?? DEFAULT_LAYOUT.orientation, }; } else if (initialData) { initialLoaded.current = true; @@ -187,11 +192,13 @@ export function useLatex(initialData) { setFontSize(initialData.fontSize ?? DEFAULT_LAYOUT.fontSize); setSpacing(initialData.spacing ?? DEFAULT_LAYOUT.spacing); setMargins(initialData.margins ?? DEFAULT_LAYOUT.margins); + setOrientation(initialData.orientation ?? DEFAULT_LAYOUT.orientation); lastCompiledLayoutRef.current = { columns: initialData.columns ?? DEFAULT_LAYOUT.columns, fontSize: initialData.fontSize ?? DEFAULT_LAYOUT.fontSize, spacing: initialData.spacing ?? DEFAULT_LAYOUT.spacing, margins: initialData.margins ?? DEFAULT_LAYOUT.margins, + orientation: initialData.orientation ?? DEFAULT_LAYOUT.orientation, }; } }, [initialData]); @@ -208,12 +215,12 @@ export function useLatex(initialData) { useEffect(() => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { - saveLatexStorage({ title, content, contentSource, columns, fontSize, spacing, margins }); + saveLatexStorage({ title, content, contentSource, columns, fontSize, spacing, margins, orientation }); }, SAVE_DEBOUNCE_MS); return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; - }, [title, content, contentSource, columns, fontSize, spacing, margins]); + }, [title, content, contentSource, columns, fontSize, spacing, margins, orientation]); const compileLatexContent = useCallback(async (latexContent, layoutOptions = {}) => { const response = await fetch('/api/compile/', { @@ -248,6 +255,7 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, }), }); @@ -258,7 +266,7 @@ export function useLatex(initialData) { const data = await response.json(); return data.tex_code; - }, [columns, fontSize, spacing, margins]); + }, [columns, fontSize, spacing, margins, orientation]); const normalizeLatexContent = useCallback(async (latexContent) => { const response = await fetch('/api/compile/', { @@ -273,6 +281,7 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, normalize_only: true, }), }); @@ -284,13 +293,15 @@ export function useLatex(initialData) { const data = await response.json(); return data.tex_code || latexContent; - }, [authTokens, columns, fontSize, margins, spacing]); + }, [authTokens, columns, fontSize, margins, spacing, orientation]); const hasLayoutChanges = lastCompiledLayoutRef.current.columns !== columns || lastCompiledLayoutRef.current.fontSize !== fontSize || lastCompiledLayoutRef.current.spacing !== spacing || - lastCompiledLayoutRef.current.margins !== margins; + lastCompiledLayoutRef.current.margins !== margins || + lastCompiledLayoutRef.current.orientation !== orientation; + const canRegenerateFromSelections = !content.trim() || contentSource === 'generated'; const handleCompileOnly = useCallback(async (selectedList = []) => { @@ -328,8 +339,9 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, }); - lastCompiledLayoutRef.current = { columns, fontSize, spacing, margins }; + lastCompiledLayoutRef.current = { columns, fontSize, spacing, margins, orientation }; setContentModified(false); } catch (error) { setCompileError(error.message); @@ -337,7 +349,7 @@ export function useLatex(initialData) { setIsCompiling(false); isCompilingRef.current = false; } - }, [clearAutoCompileTimer, columns, compileLatexContent, content, fontSize, generateLatexContent, hasLayoutChanges, margins, normalizeLatexContent, saveToHistory, spacing]); + }, [clearAutoCompileTimer, columns, compileLatexContent, content, fontSize, generateLatexContent, hasLayoutChanges, margins, normalizeLatexContent, saveToHistory, spacing, orientation]); useEffect(() => { if (!initialLoaded.current) return; @@ -372,7 +384,8 @@ export function useLatex(initialData) { columns: regenerateOptions.columns, font_size: regenerateOptions.fontSize, spacing: regenerateOptions.spacing, - margins: margins + margins: margins, + orientation: orientation }), }); if (response.ok) { @@ -401,8 +414,9 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, }); - lastCompiledLayoutRef.current = { columns, fontSize, spacing, margins }; + lastCompiledLayoutRef.current = { columns, fontSize, spacing, margins, orientation }; setContentModified(false); } catch (error) { setCompileError(error.message); @@ -410,7 +424,7 @@ export function useLatex(initialData) { setIsCompiling(false); isCompilingRef.current = false; } - }, [clearAutoCompileTimer, columns, compileLatexContent, content, fontSize, hasLayoutChanges, margins, normalizeLatexContent, saveToHistory, spacing]); + }, [clearAutoCompileTimer, columns, compileLatexContent, content, fontSize, hasLayoutChanges, margins, normalizeLatexContent, saveToHistory, spacing, orientation]); useEffect(() => { if (!initialLoaded.current || hasRestoredPreviewRef.current) return; @@ -472,6 +486,7 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, }), }); if (!response.ok) { @@ -560,6 +575,7 @@ export function useLatex(initialData) { setFontSize(initialData?.fontSize ?? DEFAULT_LAYOUT.fontSize); setSpacing(initialData?.spacing ?? DEFAULT_LAYOUT.spacing); setMargins(initialData?.margins ?? DEFAULT_LAYOUT.margins); + setOrientation(initialData?.orientation ?? DEFAULT_LAYOUT.orientation); setHistory([]); setHistoryIndex(-1); lastCompiledLayoutRef.current = { @@ -567,6 +583,7 @@ export function useLatex(initialData) { fontSize: initialData?.fontSize ?? DEFAULT_LAYOUT.fontSize, spacing: initialData?.spacing ?? DEFAULT_LAYOUT.spacing, margins: initialData?.margins ?? DEFAULT_LAYOUT.margins, + orientation: initialData?.orientation ?? DEFAULT_LAYOUT.orientation, }; if (pdfBlobUrlRef.current) { URL.revokeObjectURL(pdfBlobUrlRef.current); @@ -595,6 +612,8 @@ export function useLatex(initialData) { setSpacing, margins, setMargins, + orientation, + setOrientation, pdfBlob, isGenerating, isCompiling, @@ -612,4 +631,4 @@ export function useLatex(initialData) { handlePrintPDF, clearLatex }; -} +} \ No newline at end of file diff --git a/frontend/src/hooks/latex.test.jsx b/frontend/src/hooks/latex.test.jsx index bc850fd..522884a 100644 --- a/frontend/src/hooks/latex.test.jsx +++ b/frontend/src/hooks/latex.test.jsx @@ -45,6 +45,7 @@ describe('useLatex hook', () => { expect(result.current.fontSize).toBe('9pt'); expect(result.current.spacing).toBe('small'); expect(result.current.margins).toBe('0.15in'); + expect(result.current.orientation).toBe('portrait'); // <-- Added orientation default expect(result.current.pdfBlob).toBeNull(); expect(result.current.compileError).toBeNull(); }); @@ -56,7 +57,8 @@ describe('useLatex hook', () => { columns: 3, fontSize: '12pt', spacing: 'medium', - margins: '0.5in' + margins: '0.5in', + orientation: 'landscape' // <-- Added orientation custom data }; const { result } = renderHook(() => useLatex(initialData), { wrapper }); @@ -67,6 +69,7 @@ describe('useLatex hook', () => { expect(result.current.fontSize).toBe('12pt'); expect(result.current.spacing).toBe('medium'); expect(result.current.margins).toBe('0.5in'); + expect(result.current.orientation).toBe('landscape'); // <-- Added orientation assertion }); test('treats persisted generated sheets as safe to regenerate', () => { @@ -242,4 +245,4 @@ describe('useLatex hook', () => { expect(mockClick).toHaveBeenCalled(); expect(mockElement.download).toBe('FileTitle.tex'); }); -}); +}); \ No newline at end of file