diff --git a/tcf_website/api/serializers.py b/tcf_website/api/serializers.py new file mode 100644 index 000000000..9651a3405 --- /dev/null +++ b/tcf_website/api/serializers.py @@ -0,0 +1,215 @@ +"""DRF Serializers""" + +from rest_framework import serializers + +from ..models import ( + Club, + ClubCategory, + Course, + Department, + Instructor, + School, + Semester, + Subdepartment, +) + + +class SemesterSerializer(serializers.ModelSerializer): + """DRF Serializer for Semester""" + + season = serializers.SerializerMethodField() + + def get_season(self, obj): + """Change the `season` field to TitleCase (or PascalCase)""" + return obj.season.title() + + class Meta: + model = Semester + fields = "__all__" + + +class SchoolSerializer(serializers.ModelSerializer): + """DRF Serializer for School""" + + class Meta: + model = School + fields = "__all__" + + +class DepartmentSerializer(serializers.ModelSerializer): + """DRF Serializer for Department""" + + school = SchoolSerializer(read_only=True) + + class Meta: + model = Department + fields = "__all__" + + +class SubdepartmentSerializer(serializers.ModelSerializer): + """DRF Serializer for Subdepartment""" + + class Meta: + model = Subdepartment + fields = "__all__" + + +class CourseSerializer(serializers.ModelSerializer): + """DRF Serializer for Course""" + + subdepartment = SubdepartmentSerializer(read_only=True) + + class Meta: + model = Course + fields = "__all__" + + +class CourseSimpleStatsSerializer(CourseSerializer): + """DRF Serializer for Course including some review statistics""" + + semester_last_taught = SemesterSerializer(read_only=True) + average_rating = serializers.FloatField(allow_null=True) + average_difficulty = serializers.FloatField(allow_null=True) + average_gpa = serializers.FloatField(allow_null=True) + + class Meta: + model = Course + fields = [ + "id", + "title", + "description", + "number", + "subdepartment", + "semester_last_taught", + "average_rating", + "average_difficulty", + "average_gpa", + "is_recent", + ] + + +class CourseAllStatsSerializer(CourseSimpleStatsSerializer): + """DRF Serializer for Course including all review statistics""" + + # ratings + average_instructor = serializers.FloatField(allow_null=True) + average_fun = serializers.FloatField(allow_null=True) + average_recommendability = serializers.FloatField(allow_null=True) + # workload + average_hours_per_week = serializers.FloatField(allow_null=True) + average_amount_reading = serializers.FloatField(allow_null=True) + average_amount_writing = serializers.FloatField(allow_null=True) + average_amount_group = serializers.FloatField(allow_null=True) + average_amount_homework = serializers.FloatField(allow_null=True) + # grades + a_plus = serializers.IntegerField(allow_null=True) + a = serializers.IntegerField(allow_null=True) + a_minus = serializers.IntegerField(allow_null=True) + b_plus = serializers.IntegerField(allow_null=True) + b = serializers.IntegerField(allow_null=True) + b_minus = serializers.IntegerField(allow_null=True) + c_plus = serializers.IntegerField(allow_null=True) + c = serializers.IntegerField(allow_null=True) + c_minus = serializers.IntegerField(allow_null=True) + dfw = serializers.IntegerField(allow_null=True) + total_enrolled = serializers.IntegerField(allow_null=True) + + class Meta: + model = Course + fields = [ + "id", + "title", + "description", + "number", + "subdepartment", + "semester_last_taught", + "is_recent", + # ratings + "average_rating", + "average_instructor", + "average_fun", + "average_recommendability", + "average_difficulty", + # workload + "average_hours_per_week", + "average_amount_reading", + "average_amount_writing", + "average_amount_group", + "average_amount_homework", + # grades + "a_plus", + "a", + "a_minus", + "b_plus", + "b", + "b_minus", + "c_plus", + "c", + "c_minus", + "dfw", + "total_enrolled", + "average_gpa", + ] + + +class CourseAutocompleteSerializer(serializers.ModelSerializer): + """DRF Serializer for autocomplete course""" + + subdepartment = serializers.CharField( + source="subdepartment.mnemonic", read_only=True + ) + + class Meta: + model = Course + fields = ["id", "title", "number", "subdepartment"] + + +class InstructorSerializer(serializers.ModelSerializer): + """DRF Serializer for Instructor""" + + class Meta: + model = Instructor + fields = "__all__" + + +class InstructorAutocompleteSerializer(serializers.ModelSerializer): + """DRF Serializer for autocomplete instructor""" + + class Meta: + model = Instructor + fields = [ + "id", + "full_name", + ] + + +class ClubCategorySerializer(serializers.ModelSerializer): + """DRF Serializer for ClubCategory""" + + class Meta: + model = ClubCategory + fields = "__all__" + + +class ClubSerializer(serializers.ModelSerializer): + """DRF Serializer for Club""" + + category = ClubCategorySerializer(read_only=True) + + class Meta: + model = Club + fields = "__all__" + + +class ClubAutocompleteSerializer(serializers.ModelSerializer): + """DEF Serializer for Club autocomplete""" + + category_name = serializers.CharField(source="category.name", read_only=True) + + class Meta: + model = Club + fields = ( + "id", + "name", + "category_name", + ) diff --git a/tcf_website/static/search/autocomplete.js b/tcf_website/static/search/autocomplete.js new file mode 100644 index 000000000..d4a359da0 --- /dev/null +++ b/tcf_website/static/search/autocomplete.js @@ -0,0 +1,189 @@ +document.addEventListener("DOMContentLoaded", function () { + const searchInput = document.getElementById("search-input"); + if (!searchInput) return; + + // Create container for autocomplete suggestions + const suggestionsContainer = document.createElement("div"); + suggestionsContainer.classList.add("autocomplete-suggestions"); + + const searchbarWrapper = searchInput.closest(".searchbar-wrapper"); + if (searchbarWrapper) { + searchbarWrapper.appendChild(suggestionsContainer); + } else { + return; + } + + let debounceTimeout = null; + let currentRequestController = null; + + // Debounce to reduce API calls + function debounce(func, delay) { + return function (...args) { + clearTimeout(debounceTimeout); + debounceTimeout = setTimeout(() => func(...args), delay); + }; + } + + function getSearchMode() { + const checked = document.querySelector('input[name="mode"]:checked'); + return checked ? checked.value : "courses"; + } + + async function fetchSuggestions(query) { + if (!query.trim()) { + clearSuggestions(); + return; + } + + // Cancel previous request if still in-flight + if (currentRequestController) { + currentRequestController.abort(); + } + + currentRequestController = new AbortController(); + const signal = currentRequestController.signal; + + const mode = getSearchMode(); + + try { + const response = await fetch( + `/api/autocomplete/?q=${encodeURIComponent(query)}&mode=${mode}`, + { signal: signal }, + ); + + if (!response.ok) { + clearSuggestions(); + return; + } + + const data = await response.json(); + renderSuggestions(data); + } catch (error) { + if (error.name !== "AbortError") { + console.error("Autocomplete fetch error:", error); + } + clearSuggestions(); + } + } + + // Render suggestions + function renderSuggestions(data) { + suggestionsContainer.innerHTML = ""; + + const hasCourses = data?.courses?.length > 0; + const hasInstructors = data?.instructors?.length > 0; + const hasClubs = data?.clubs?.length > 0; + + if (!hasCourses && !hasInstructors && !hasClubs) { + suggestionsContainer.style.display = "none"; + return; + } + + let MAX_RESULTS = 8; + if (window.innerWidth < 600) { + MAX_RESULTS = 4; + } + if (window.innerWidth < 1024) { + MAX_RESULTS = 6; + } + const courses = (data.courses || []).slice(0, MAX_RESULTS); + const instructors = (data.instructors || []).slice(0, MAX_RESULTS); + const clubs = (data.clubs || []).slice(0, MAX_RESULTS); + + // Add group headers for clarity + if (hasCourses) { + const header = document.createElement("div"); + header.classList.add("autocomplete-header"); + header.textContent = "Courses"; + suggestionsContainer.appendChild(header); + } + + // Courses first + courses.forEach((course) => { + const item = document.createElement("div"); + item.classList.add("autocomplete-item"); + item.style.cursor = "pointer"; + item.textContent = `${course.subdepartment} ${course.number} — ${course.title}`; + item.addEventListener("click", () => { + searchInput.value = `${course.subdepartment} ${course.number}`; + clearSuggestions(); + if (searchInput.form) { + searchInput.form.submit(); + } + }); + suggestionsContainer.appendChild(item); + }); + + if (instructors.length > 0) { + const header = document.createElement("div"); + header.classList.add("autocomplete-header"); + header.textContent = "Instructors"; + suggestionsContainer.appendChild(header); + } + + // Instructor results + instructors.forEach((instructor) => { + const item = document.createElement("div"); + item.classList.add("autocomplete-item"); + item.style.cursor = "pointer"; + item.textContent = instructor.full_name; + item.addEventListener("click", () => { + searchInput.value = instructor.full_name; + clearSuggestions(); + if (searchInput.form) { + searchInput.form.submit(); + } + }); + suggestionsContainer.appendChild(item); + }); + + if (clubs.length > 0) { + const header = document.createElement("div"); + header.classList.add("autocomplete-header"); + header.textContent = "Clubs"; + suggestionsContainer.appendChild(header); + } + + // Club results + clubs.forEach((club) => { + const item = document.createElement("div"); + item.classList.add("autocomplete-item"); + item.style.cursor = "pointer"; + item.textContent = club.name; + item.addEventListener("click", () => { + searchInput.value = club.name; + clearSuggestions(); + if (searchInput.form) { + searchInput.form.submit(); + } + }); + suggestionsContainer.appendChild(item); + }); + + suggestionsContainer.style.display = "block"; + } + + // Helper to clear dropdown + function clearSuggestions() { + suggestionsContainer.innerHTML = ""; + suggestionsContainer.style.display = "none"; + } + + // Hide dropdown when clicking outside + document.addEventListener("click", (event) => { + if ( + !suggestionsContainer.contains(event.target) && + event.target !== searchInput + ) { + clearSuggestions(); + } + }); + + // Debounced input listener + searchInput.addEventListener( + "input", + debounce((event) => { + fetchSuggestions(event.target.value); + }, 250), + ); +}); diff --git a/tcf_website/static/search/searchbar.css b/tcf_website/static/search/searchbar.css index 759175e12..5bf561da3 100644 --- a/tcf_website/static/search/searchbar.css +++ b/tcf_website/static/search/searchbar.css @@ -303,3 +303,58 @@ background-color: #cbd5e0; border-radius: 2px; } + +/* Autocomplete suggestions positioning */ +.searchbar-wrapper { + position: relative; +} + +/* Remove focus outline from search input */ +#search-input:focus { + outline: none !important; + box-shadow: none !important; + border-color: inherit; +} + +.autocomplete-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + background: white; + border: 1px solid #e2e8f0; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-top: 0; + max-height: 400px; + overflow: hidden; + display: none; + padding: 0.5rem 0; +} + +.autocomplete-header { + padding: 0.75rem 1rem; + font-weight: 600; + font-size: 0.875rem; + color: #4a5568; + background-color: #f7fafc; + border-bottom: 1px solid #e2e8f0; + margin: 0; +} + +.autocomplete-item { + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 1px solid #f7fafc; + margin: 0; + transition: background-color 0.15s ease; +} + +.autocomplete-item:hover { + background-color: #f7fafc; +} + +.autocomplete-item:last-child { + border-bottom: none; +} diff --git a/tcf_website/templates/club/mode_toggle.html b/tcf_website/templates/club/mode_toggle.html index c4d5d0d6c..bb0235251 100644 --- a/tcf_website/templates/club/mode_toggle.html +++ b/tcf_website/templates/club/mode_toggle.html @@ -13,6 +13,11 @@
+ {% if is_club %} + + {% else %} + + {% endif %} {% else %}
@@ -22,4 +27,4 @@
-{% endif %} \ No newline at end of file +{% endif %} diff --git a/tcf_website/templates/search/searchbar.html b/tcf_website/templates/search/searchbar.html index 1851ad5cd..a24b29ae8 100644 --- a/tcf_website/templates/search/searchbar.html +++ b/tcf_website/templates/search/searchbar.html @@ -2,209 +2,211 @@ - -
-
- -
- - {% include "../club/mode_toggle.html" with is_club=is_club toggle_type="radio" no_transition=True container_class="search-mode-toggle" %} - - -
-