diff --git a/backend/Dockerfile b/backend/Dockerfile index 60f7893..a2e22b3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -35,4 +35,4 @@ COPY . . EXPOSE 8000 -CMD ["sh", "-c", "until mysqladmin ping -hdb -ucheatsheet_user -pcheatsheet_pass --silent; do sleep 2; done && python manage.py runserver 0.0.0.0:8000"] +CMD ["sh", "-c", "until mysqladmin ping -hdb -ucheatsheet_user -pcheatsheet_pass --ssl=0 --silent; do sleep 2; done && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"] diff --git a/backend/api/latex_utils.py b/backend/api/latex_utils.py new file mode 100644 index 0000000..f949f50 --- /dev/null +++ b/backend/api/latex_utils.py @@ -0,0 +1,103 @@ +import subprocess +import tempfile +import os + +LATEX_HEADER = r"""\documentclass[fleqn]{article} +\usepackage[margin=0.15in]{geometry} +\usepackage{amsmath, amssymb} +\usepackage{enumitem} +\usepackage{multicol} +\usepackage{titlesec} + +\setlength{\mathindent}{0pt} +\setlist[itemize]{noitemsep, topsep=0pt, leftmargin=*} +\pagestyle{empty} + +\titlespacing*{\subsection}{0pt}{2pt}{1pt} +\titlespacing*{\section}{0pt}{4pt}{2pt} + +\begin{document} +\scriptsize +""" + +LATEX_FOOTER = r""" +\end{document} +""" + +def build_latex_for_formulas(selected_formulas): + """ + Given a list of selected formulas (each with class_name, category, name, latex), + build a complete LaTeX document. + """ + body_lines = [] + + # Group formulas by class + by_class = {} + for formula in selected_formulas: + class_name = formula.get("class_name") or formula.get("class") + if class_name not in by_class: + by_class[class_name] = {} + + category = formula.get("category") + if category not in by_class[class_name]: + by_class[class_name][category] = [] + + by_class[class_name][category].append(formula) + + # Build LaTeX for each class + for class_name, categories in by_class.items(): + body_lines.append("\\section*{" + class_name + "}") + body_lines.append("") + + for category_name, formulas in categories.items(): + body_lines.append("\\subsection*{" + category_name + "}") + body_lines.append("") + body_lines.append(r"\begin{flushleft}") + + for formula in formulas: + name = formula.get("name", "") + latex = formula.get("latex", "") + body_lines.append("\\textbf{" + name + "}") + body_lines.append("\\[ " + latex + " \\]") + body_lines.append("\\\\[4pt]") + + body_lines.append(r"\end{flushleft}") + body_lines.append("") + + body = "\n".join(body_lines) + return LATEX_HEADER + body + LATEX_FOOTER + +def compile_latex_to_pdf(content): + """ + Compiles LaTeX content to a PDF using Tectonic. + Returns the generated PDF as bytes or raises an Exception. + """ + # Ensure document has proper structure + if r"\begin{document}" not in content: + content = LATEX_HEADER + content + LATEX_FOOTER + + # Use a context manager so the temporary directory is always cleaned up + with tempfile.TemporaryDirectory() as tempdir: + tex_file_path = os.path.join(tempdir, "document.tex") + with open(tex_file_path, "w", encoding="utf-8") as f: + f.write(content) + + try: + subprocess.run( + ["tectonic", tex_file_path], + cwd=tempdir, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError: + # Propagate the error; the temporary directory will still be cleaned up + raise + + pdf_file_path = os.path.join(tempdir, "document.pdf") + if not os.path.exists(pdf_file_path): + raise FileNotFoundError("PDF not generated") + + # 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() diff --git a/backend/api/urls.py b/backend/api/urls.py index 222a17c..748584e 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,18 +1,19 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import DefaultRouter from . import views +# Create a router and register our viewsets with it. +router = DefaultRouter() +router.register(r'templates', views.TemplateViewSet, basename='template') +router.register(r'cheatsheets', views.CheatSheetViewSet, basename='cheatsheet') +router.register(r'problems', views.PracticeProblemViewSet, basename='problem') + urlpatterns = [ path("health/", views.health_check, name="health-check"), path("classes/", views.get_classes, name="get-classes"), path("generate-sheet/", views.generate_sheet, name="generate-sheet"), path("compile/", views.compile_latex, name="compile-latex"), - # CRUD endpoints - path("templates/", views.template_list, name="template-list"), - path("templates//", views.template_detail, name="template-detail"), - path("cheatsheets/", views.cheatsheet_list, name="cheatsheet-list"), - path("cheatsheets/from-template/", views.cheatsheet_from_template, name="cheatsheet-from-template"), - path("cheatsheets//", views.cheatsheet_detail, name="cheatsheet-detail"), - path("problems/", views.problem_list, name="problem-list"), - path("problems//", views.problem_detail, name="problem-detail"), + # Include the router URLs for CRUD operations + path('', include(router.urls)), ] diff --git a/backend/api/views.py b/backend/api/views.py index a5634e3..dea755b 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,6 +1,6 @@ -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, action from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, viewsets from django.http import FileResponse from django.shortcuts import get_object_or_404 import subprocess @@ -10,77 +10,7 @@ from .models import Template, CheatSheet, PracticeProblem from .serializers import TemplateSerializer, CheatSheetSerializer, PracticeProblemSerializer from .formula_data import get_formula_data, get_classes_with_details - - -# ------------------------------------------------------------------ -# LaTeX document template with all packages -# ------------------------------------------------------------------ -LATEX_HEADER = r"""\documentclass[fleqn]{article} -\usepackage[margin=0.15in]{geometry} -\usepackage{amsmath, amssymb} -\usepackage{enumitem} -\usepackage{multicol} -\usepackage{titlesec} - -\setlength{\mathindent}{0pt} -\setlist[itemize]{noitemsep, topsep=0pt, leftmargin=*} -\pagestyle{empty} - -\titlespacing*{\subsection}{0pt}{2pt}{1pt} -\titlespacing*{\section}{0pt}{4pt}{2pt} - -\begin{document} -\scriptsize -""" - -LATEX_FOOTER = r""" -\end{document} -""" - - -def _build_latex_for_formulas(selected_formulas): - """ - Given a list of selected formulas (each with class_name, category, name, latex), - build a complete LaTeX document. - """ - body_lines = [] - - # Group formulas by class - by_class = {} - for formula in selected_formulas: - class_name = formula.get("class_name") or formula.get("class") - if class_name not in by_class: - by_class[class_name] = {} - - category = formula.get("category") - if category not in by_class[class_name]: - by_class[class_name][category] = [] - - by_class[class_name][category].append(formula) - - # Build LaTeX for each class - for class_name, categories in by_class.items(): - body_lines.append("\\section*{" + class_name + "}") - body_lines.append("") - - for category_name, formulas in categories.items(): - body_lines.append("\\subsection*{" + category_name + "}") - body_lines.append("") - body_lines.append(r"\begin{flushleft}") - - for formula in formulas: - name = formula.get("name", "") - latex = formula.get("latex", "") - body_lines.append("\\textbf{" + name + "}") - body_lines.append("\\[ " + latex + " \\]") - body_lines.append("\\\\[4pt]") - - body_lines.append(r"\end{flushleft}") - body_lines.append("") - - body = "\n".join(body_lines) - return LATEX_HEADER + body + LATEX_FOOTER - +from .latex_utils import build_latex_for_formulas, LATEX_HEADER, LATEX_FOOTER # ------------------------------------------------------------------ # API endpoints @@ -141,7 +71,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) + tex_code = build_latex_for_formulas(selected_formulas) return Response({"tex_code": tex_code}) @@ -200,148 +130,70 @@ def compile_latex(request): # ------------------------------------------------------------------ -# CRUD API endpoints for Templates, CheatSheets, and Problems +# CRUD API ViewSets for Templates, CheatSheets, and Problems # ------------------------------------------------------------------ -@api_view(["GET", "POST"]) -def template_list(request): - """GET /api/templates/ - List all templates - POST /api/templates/ - Create a new template""" - if request.method == "GET": - subject = request.query_params.get("subject") - templates = Template.objects.all() - if subject: - templates = templates.filter(subject=subject) - serializer = TemplateSerializer(templates, many=True) - return Response(serializer.data) - - elif request.method == "POST": - serializer = TemplateSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -@api_view(["GET", "PUT", "PATCH", "DELETE"]) -def template_detail(request, pk): - """GET/PUT/PATCH/DELETE /api/templates/{id}/""" - template = get_object_or_404(Template, pk=pk) - - if request.method == "GET": - serializer = TemplateSerializer(template) - return Response(serializer.data) - - elif request.method in ["PUT", "PATCH"]: - serializer = TemplateSerializer(template, data=request.data, partial=(request.method == "PATCH")) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - elif request.method == "DELETE": - template.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -@api_view(["GET", "POST"]) -def cheatsheet_list(request): - """GET /api/cheatsheets/ - List all cheat sheets - POST /api/cheatsheets/ - Create a new cheat sheet""" - if request.method == "GET": - cheatsheets = CheatSheet.objects.all() - serializer = CheatSheetSerializer(cheatsheets, many=True) - return Response(serializer.data) - - elif request.method == "POST": - serializer = CheatSheetSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +class TemplateViewSet(viewsets.ModelViewSet): + """ + CRUD API for Templates + Get/Post/Put/Delete /api/templates/ + """ + queryset = Template.objects.all() + serializer_class = TemplateSerializer + def get_queryset(self): + queryset = super().get_queryset() + subject = self.request.query_params.get('subject') + if subject: + queryset = queryset.filter(subject=subject) + return queryset -@api_view(["GET", "POST"]) -def cheatsheet_from_template(request): - """POST /api/cheatsheets/from-template/ - Create cheat sheet from template""" - template_id = request.data.get("template_id") - title = request.data.get("title", "Untitled") - - if not template_id: - return Response({"error": "template_id is required"}, status=status.HTTP_400_BAD_REQUEST) - - template = get_object_or_404(Template, pk=template_id) - - cheatsheet = CheatSheet.objects.create( - title=title, - template=template, - latex_content=template.latex_content, - margins=template.default_margins, - columns=template.default_columns, - ) - - serializer = CheatSheetSerializer(cheatsheet) - return Response(serializer.data, status=status.HTTP_201_CREATED) +class CheatSheetViewSet(viewsets.ModelViewSet): + """ + CRUD API for CheatSheets + Get/Post/Put/Delete /api/cheatsheets/ + """ + queryset = CheatSheet.objects.all() + serializer_class = CheatSheetSerializer + + @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") + + if not template_id: + return Response({"error": "template_id is required"}, status=status.HTTP_400_BAD_REQUEST) + + template = get_object_or_404(Template, pk=template_id) + + cheatsheet = CheatSheet.objects.create( + title=title, + template=template, + latex_content=template.latex_content, + margins=template.default_margins, + columns=template.default_columns, + ) + + serializer = self.get_serializer(cheatsheet) + return Response(serializer.data, status=status.HTTP_201_CREATED) -@api_view(["GET", "PUT", "PATCH", "DELETE"]) -def cheatsheet_detail(request, pk): - """GET/PUT/PATCH/DELETE /api/cheatsheets/{id}/""" - cheatsheet = get_object_or_404(CheatSheet, pk=pk) - - if request.method == "GET": - serializer = CheatSheetSerializer(cheatsheet) - return Response(serializer.data) - - elif request.method in ["PUT", "PATCH"]: - serializer = CheatSheetSerializer(cheatsheet, data=request.data, partial=(request.method == "PATCH")) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - elif request.method == "DELETE": - cheatsheet.delete() - return Response(status=status.HTTP_204_NO_CONTENT) +class PracticeProblemViewSet(viewsets.ModelViewSet): + """ + CRUD API for Practice Problems + Get/Post/Put/Delete /api/problems/ + """ + queryset = PracticeProblem.objects.all() + serializer_class = PracticeProblemSerializer -@api_view(["GET", "POST"]) -def problem_list(request): - """GET /api/problems/ - List problems (optionally filtered by cheat_sheet) - POST /api/problems/ - Create a new problem""" - if request.method == "GET": - cheat_sheet_id = request.query_params.get("cheat_sheet") + def get_queryset(self): + queryset = super().get_queryset() + cheat_sheet_id = self.request.query_params.get('cheat_sheet') if cheat_sheet_id: - problems = PracticeProblem.objects.filter(cheat_sheet=cheat_sheet_id) - else: - problems = PracticeProblem.objects.all() - serializer = PracticeProblemSerializer(problems, many=True) - return Response(serializer.data) - - elif request.method == "POST": - serializer = PracticeProblemSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -@api_view(["GET", "PUT", "PATCH", "DELETE"]) -def problem_detail(request, pk): - """GET/PUT/PATCH/DELETE /api/problems/{id}/""" - problem = get_object_or_404(PracticeProblem, pk=pk) - - if request.method == "GET": - serializer = PracticeProblemSerializer(problem) - return Response(serializer.data) - - elif request.method in ["PUT", "PATCH"]: - serializer = PracticeProblemSerializer(problem, data=request.data, partial=(request.method == "PATCH")) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - elif request.method == "DELETE": - problem.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + queryset = queryset.filter(cheat_sheet=cheat_sheet_id) + return queryset diff --git a/docker-compose.yml b/docker-compose.yml index 95b760a..d6c916c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: db: - image: mariadb:11 + image: mysql:8.0 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: rootpass @@ -10,7 +10,7 @@ services: # ports: comment out for now we'e not using atm # - "3306:3306" volumes: - - mariadb_data:/var/lib/mysql + - mysql_data:/var/lib/mysql backend: build: ./backend ports: @@ -39,4 +39,4 @@ services: backend: condition: service_healthy volumes: - mariadb_data: + mysql_data: diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx index 76009d1..21b38cb 100644 --- a/frontend/src/components/CreateCheatSheet.jsx +++ b/frontend/src/components/CreateCheatSheet.jsx @@ -1,194 +1,190 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React from 'react'; +import { useFormulas } from '../hooks/formulas'; +import { useLatex } from '../hooks/latex'; -const CreateCheatSheet = ({ onSave, initialData }) => { - const [title, setTitle] = useState(initialData ? initialData.title : ''); - const [content, setContent] = useState(initialData ? initialData.content : ''); - const [pdfBlob, setPdfBlob] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isCompiling, setIsCompiling] = useState(false); - const isCompilingRef = useRef(false); +// Subcomponents - // Formula selection state - const [classesData, setClassesData] = useState([]); - const [selectedClasses, setSelectedClasses] = useState({}); // { "ClassName": true } - const [selectedCategories, setSelectedCategories] = useState({}); // { "ClassName:CategoryName": true } - const [isGenerating, setIsGenerating] = useState(false); - const isGeneratingRef = useRef(false); +const FormulaSelection = ({ + classesData, + selectedClasses, + selectedCategories, + toggleClass, + toggleCategory, + onGenerate, + isGenerating, + selectedCount, + hasSelectedClasses +}) => ( +
+ + +
+ {classesData.map((cls) => { + const isChecked = !!selectedClasses[cls.name]; + return ( + + ); + })} +
- // Fetch the full class/category/formula structure from backend - useEffect(() => { - fetch('/api/classes/') - .then((res) => res.json()) - .then((data) => { - setClassesData(data.classes || []); - }) - .catch((err) => console.error('Failed to fetch classes', err)); - }, []); + {hasSelectedClasses && ( +
+ + + {classesData.map((cls) => { + if (!selectedClasses[cls.name]) return null; + + return ( +
+ +
+ {cls.categories.map((cat) => { + const key = `${cls.name}:${cat.name}`; + const isChecked = !!selectedCategories[key]; + return ( + + ); + })} +
+
+ ); + })} +
+ )} - useEffect(() => { - if (initialData) { - if (initialData.title) setTitle(initialData.title); - if (initialData.content) setContent(initialData.content); - } - }, [initialData]); + - // Toggle class selection - const toggleClass = (className) => { - setSelectedClasses((prev) => { - const newSelected = { ...prev }; - if (newSelected[className]) { - delete newSelected[className]; - // Clear categories for this class - Object.keys(selectedCategories).forEach((key) => { - if (key.startsWith(className + ':')) { - delete newSelected[key]; - } - }); - } else { - newSelected[className] = true; - } - return newSelected; - }); - }; + {selectedCount > 0 && ( +

+ {selectedCount} formula(s) will be included +

+ )} +
+); - // Toggle category selection - const toggleCategory = (className, categoryName) => { - const key = `${className}:${categoryName}`; - setSelectedCategories((prev) => { - const newSelected = { ...prev }; - if (newSelected[key]) { - delete newSelected[key]; - } else { - newSelected[key] = true; - } - return newSelected; - }); - }; +const LatexEditor = ({ content, setContent, handlePreview, isCompiling }) => ( + <> +
+ +