diff --git a/tcf_website/api/serializers.py b/tcf_website/api/serializers.py index d7d66f7df..9651a3405 100644 --- a/tcf_website/api/serializers.py +++ b/tcf_website/api/serializers.py @@ -152,6 +152,18 @@ class Meta: ] +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""" @@ -160,6 +172,17 @@ class Meta: 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""" @@ -176,3 +199,17 @@ class ClubSerializer(serializers.ModelSerializer): 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..31f9d7a0d --- /dev/null +++ b/tcf_website/static/search/autocomplete.js @@ -0,0 +1,183 @@ +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; + } + + const MAX_RESULTS = 8; + 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 %}