From 853b69ad533a87ae5c28dccf6b8352aa8124dc93 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Mon, 8 Dec 2025 19:37:57 +0800 Subject: [PATCH 01/17] chore(env): update env example --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index cf49b2b..90fd2ba 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # the env variable used for vite-processed frontend should begin with `VITE_` VITE_TURNSTILE_SITE_KEY=0x4AAAAAABz2ci0ZN9OaO-dg VITE_AUTH_OTP_TIMEOUT=120 -VITE_AUTH_TEMP_TOKEN_TIMEOUT=600 \ No newline at end of file +VITE_AUTH_TEMP_TOKEN_TIMEOUT=600 +VITE_API_BASE_URL= From dfd9a9501b7baef15e00c479b65e39683136a68c Mon Sep 17 00:00:00 2001 From: arthurcai Date: Mon, 8 Dec 2025 19:38:37 +0800 Subject: [PATCH 02/17] chore(package): update package json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 211f0d5..87899c6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@eslint/js": "^9.38.0", "@vitejs/plugin-vue": "^5.2.4", "autoprefixer": "^10.4.21", + "baseline-browser-mapping": "^2.9.4", "oxfmt": "^0.8.0", "oxlint": "^1.24.0", "vite": "^6.4.1" From f95b6daea407aa7dc37e09bd2b3720b8467c4ea3 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Mon, 8 Dec 2025 19:40:50 +0800 Subject: [PATCH 03/17] fix: update vue scripts to adapt backend and fix requests --- src/views/CourseDetail.vue | 18 +++++++++++------- src/views/CourseReviewSearch.vue | 27 +++++++++++++++++++-------- src/views/Home.vue | 3 ++- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/views/CourseDetail.vue b/src/views/CourseDetail.vue index f84fd57..f55ed78 100644 --- a/src/views/CourseDetail.vue +++ b/src/views/CourseDetail.vue @@ -617,6 +617,7 @@ import { import { MdEditor, MdPreview } from "md-editor-v3"; import "md-editor-v3/lib/style.css"; import { sanitize } from "../utils/sanitize"; +import { apiFetch } from "../utils/api"; import { useAuth } from "../composables/useAuth"; import { useReviews } from "../composables/useReviews"; import ReviewPagination from "../components/ReviewPagination.vue"; @@ -680,7 +681,7 @@ const fetchCourse = async () => { loading.value = true; error.value = null; try { - const response = await fetch(`/api/course/${courseId.value}/`); + const response = await apiFetch(`/api/courses/${courseId.value}/`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -798,6 +799,11 @@ const deleteReview = async () => { return; } + if (!userReview.value?.id) { + alert("No review to delete."); + return; + } + if ( !confirm("Are you sure you want to delete your review for this course?") ) { @@ -805,12 +811,10 @@ const deleteReview = async () => { } try { - const updatedCourse = await deleteReviewFn(courseId.value); - if (updatedCourse) { - course.value = updatedCourse; - userReview.value = null; - alert("Review deleted successfully!"); - } + await deleteReviewFn(userReview.value.id); + userReview.value = null; + course.value.can_write_review = true; + alert("Review deleted successfully!"); } catch (error) { console.error("Error deleting review:", error); alert(`Error deleting review: ${error.message}`); diff --git a/src/views/CourseReviewSearch.vue b/src/views/CourseReviewSearch.vue index d91536a..8afe909 100644 --- a/src/views/CourseReviewSearch.vue +++ b/src/views/CourseReviewSearch.vue @@ -75,6 +75,7 @@ import { useRoute, useRouter } from "vue-router"; import { MagnifyingGlassIcon } from "@heroicons/vue/20/solid"; import "md-editor-v3/lib/style.css"; import { sanitize } from "../utils/sanitize"; +import { apiFetch } from "../utils/api"; import { useAuth } from "../composables/useAuth"; import ReviewPagination from "../components/ReviewPagination.vue"; @@ -92,11 +93,22 @@ const reviews = ref([]); const loading = ref(true); const error = ref(null); const reviewsFullCount = ref(0); -const remaining = ref(0); const courseShortName = ref(""); const query = ref(""); const { isAuthenticated, checkAuthentication } = useAuth(); +const fetchCourseInfo = async () => { + try { + const response = await apiFetch(`/api/courses/${props.courseId}/`); + if (response.ok) { + const data = await response.json(); + courseShortName.value = data.course_code; + } + } catch (e) { + console.error("Error fetching course info:", e); + } +}; + const fetchReviews = async () => { loading.value = true; error.value = null; @@ -108,8 +120,8 @@ const fetchReviews = async () => { } try { - const response = await fetch( - `/api/course/${props.courseId}/review_search/?q=${encodeURIComponent( + const response = await apiFetch( + `/api/courses/${props.courseId}/reviews/?q=${encodeURIComponent( searchQuery.value, )}`, ); @@ -124,11 +136,9 @@ const fetchReviews = async () => { return; } const data = await response.json(); - reviews.value = data.reviews; - reviewsFullCount.value = data.reviews_full_count; - remaining.value = data.remaining; - courseShortName.value = data.course_short_name; - query.value = data.query; + reviews.value = data; + reviewsFullCount.value = data.length; + query.value = searchQuery.value; } catch (e) { error.value = e.message; } finally { @@ -158,6 +168,7 @@ watch(isAuthenticated, (newAuth) => { onMounted(async () => { searchQuery.value = route.query.q || ""; await checkAuthentication(); + await fetchCourseInfo(); await fetchReviews(); }); diff --git a/src/views/Home.vue b/src/views/Home.vue index 985f149..bcc75e1 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -141,6 +141,7 @@ import { ref, onMounted } from "vue"; import { useRouter } from "vue-router"; import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline"; import { useAuth } from "../composables/useAuth"; +import { apiFetch } from "../utils/api"; const router = useRouter(); const reviewCount = ref(0); @@ -153,7 +154,7 @@ onMounted(async () => { const fetchLandingData = async () => { try { - const response = await fetch("/api/landing/"); + const response = await apiFetch("/api/landing/"); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } From 58f9f6b2ce2aace37882c543ff897202d97824a5 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Mon, 8 Dec 2025 19:41:10 +0800 Subject: [PATCH 04/17] fix: update jsons for api usage --- src/composables/useCourses.js | 15 ++++++++------- src/composables/useReviews.js | 19 +++++++++++-------- src/utils/api.js | 10 ++++++++-- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/composables/useCourses.js b/src/composables/useCourses.js index 9b4a4cd..86d8a1c 100644 --- a/src/composables/useCourses.js +++ b/src/composables/useCourses.js @@ -1,4 +1,5 @@ import { ref, reactive } from "vue"; +import { apiFetch } from "../utils/api"; export function useCourses() { const courses = ref([]); @@ -27,7 +28,7 @@ export function useCourses() { const fetchDepartments = async () => { try { - const response = await fetch("/api/departments/"); + const response = await apiFetch("/api/departments/"); if (!response.ok) throw new Error("Failed to fetch departments"); departments.value = await response.json(); } catch (e) { @@ -49,7 +50,7 @@ export function useCourses() { params.append("page", pagination.current_page); try { - const response = await fetch(`/api/courses/?${params.toString()}`); + const response = await apiFetch(`/api/courses/?${params.toString()}`); if (!response.ok) { const errorData = await response .json() @@ -59,11 +60,11 @@ export function useCourses() { ); } const data = await response.json(); - courses.value = data.courses; - pagination.current_page = data.pagination.current_page; - pagination.total_pages = data.pagination.total_pages; - pagination.total_courses = data.pagination.total_courses; - pagination.limit = data.pagination.limit; + // DRF 标准分页格式: { count, next, previous, results } + courses.value = data.results || []; + const totalCount = data.count || 0; + pagination.total_courses = totalCount; + pagination.total_pages = Math.ceil(totalCount / pagination.limit) || 1; } catch (e) { console.error("useCourses: Error fetching courses:", e); error.value = e.message; diff --git a/src/composables/useReviews.js b/src/composables/useReviews.js index 9b5c788..a0ee11b 100644 --- a/src/composables/useReviews.js +++ b/src/composables/useReviews.js @@ -1,5 +1,6 @@ import { ref } from "vue"; import { getCookie } from "../utils/cookies"; +import { apiFetch } from "../utils/api"; export function useReviews() { const loading = ref(false); @@ -8,7 +9,9 @@ export function useReviews() { const fetchUserReview = async (courseId) => { if (!courseId) return null; try { - const response = await fetch(`/api/course/${courseId}/my-review/`); + const response = await apiFetch( + `/api/courses/${courseId}/reviews/?author=me`, + ); if (response.ok) { const data = await response.json(); return Array.isArray(data) ? data[0] : data; @@ -29,7 +32,7 @@ export function useReviews() { const submitReview = async (courseId, newReview) => { try { - const response = await fetch(`/api/course/${courseId}/`, { + const response = await apiFetch(`/api/courses/${courseId}/reviews/`, { method: "POST", headers: { "Content-Type": "application/json", @@ -48,17 +51,17 @@ export function useReviews() { } }; - const deleteReview = async (courseId) => { + const deleteReview = async (reviewId) => { try { - const response = await fetch(`/api/course/${courseId}/review/`, { + const response = await apiFetch(`/api/reviews/${reviewId}/`, { method: "DELETE", headers: { "X-CSRFToken": getCookie("csrftoken") }, }); - if (!response.ok) { + if (!response.ok && response.status !== 204) { const errorData = await response.json().catch(() => null); throw new Error(errorData?.detail || "Failed to delete review"); } - return await response.json(); + return true; } catch (e) { console.error("useReviews: deleteReview error", e); throw e; @@ -67,7 +70,7 @@ export function useReviews() { const vote = async (courseId, value, forLayup) => { try { - const response = await fetch(`/api/course/${courseId}/vote/`, { + const response = await apiFetch(`/api/courses/${courseId}/vote/`, { method: "POST", headers: { "Content-Type": "application/json", @@ -85,7 +88,7 @@ export function useReviews() { const voteOnReview = async (reviewId, isKudos) => { try { - const response = await fetch(`/api/review/${reviewId}/vote/`, { + const response = await apiFetch(`/api/reviews/${reviewId}/vote/`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/utils/api.js b/src/utils/api.js index c52e641..6322d0b 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -1,8 +1,14 @@ // Lightweight API utilities used across components // checkAuthentication: returns a boolean indicating auth status +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ""; + +export async function apiFetch(path, options = {}) { + return fetch(`${API_BASE_URL}${path}`, options); +} + export async function checkAuthentication() { try { - const response = await fetch("/api/user/status/"); + const response = await apiFetch("/api/user/status/"); if (response.ok) { const data = await response.json(); return !!data.isAuthenticated; @@ -14,4 +20,4 @@ export async function checkAuthentication() { } } -export default { checkAuthentication }; +export default { apiFetch, checkAuthentication }; From c5715e282b39f32b84692a71ac56d89ffdb22d19 Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 20 Jan 2026 16:46:55 +0800 Subject: [PATCH 05/17] fix: calculate course list total_pages, rename filter param num_reviews to review_count --- src/components/CourseList.vue | 6 +++--- src/composables/useCourses.js | 12 +++++++++--- src/views/CourseDetail.vue | 1 - 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/CourseList.vue b/src/components/CourseList.vue index 8e444e3..0269bc5 100644 --- a/src/components/CourseList.vue +++ b/src/components/CourseList.vue @@ -69,7 +69,7 @@ v-model.number="filters.min_quality" type="number" min="0" - class="mt-1 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="mt-1 block w-full rounded-md border-0 py-1.5 pl-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" @change="applyFiltersAndSort" /> @@ -88,12 +88,12 @@ @change="applyFiltersAndSort" > - + diff --git a/src/composables/useCourses.js b/src/composables/useCourses.js index 86d8a1c..59eaa81 100644 --- a/src/composables/useCourses.js +++ b/src/composables/useCourses.js @@ -11,7 +11,6 @@ export function useCourses() { current_page: 1, total_pages: 1, total_courses: 0, - limit: 20, }); const filters = reactive({ @@ -45,6 +44,9 @@ export function useCourses() { if (filters.code) params.append("code", filters.code.trim()); if (filters.min_quality && isAuth) params.append("min_quality", filters.min_quality); + if (filters.min_difficulty && isAuth) + params.append("min_difficulty", filters.min_difficulty); + params.append("sort_by", sorting.sort_by); params.append("sort_order", sorting.sort_order); params.append("page", pagination.current_page); @@ -60,11 +62,15 @@ export function useCourses() { ); } const data = await response.json(); - // DRF 标准分页格式: { count, next, previous, results } + // DRF pagination: { count, next, previous, results } courses.value = data.results || []; const totalCount = data.count || 0; pagination.total_courses = totalCount; - pagination.total_pages = Math.ceil(totalCount / pagination.limit) || 1; + // TODO: let backend return total pages + if (pagination.current_page == 1) { + pagination.total_pages = + Math.ceil(totalCount / courses.value.length) || 1; + } } catch (e) { console.error("useCourses: Error fetching courses:", e); error.value = e.message; diff --git a/src/views/CourseDetail.vue b/src/views/CourseDetail.vue index f55ed78..1aa7338 100644 --- a/src/views/CourseDetail.vue +++ b/src/views/CourseDetail.vue @@ -816,7 +816,6 @@ const deleteReview = async () => { course.value.can_write_review = true; alert("Review deleted successfully!"); } catch (error) { - console.error("Error deleting review:", error); alert(`Error deleting review: ${error.message}`); } }; From b05eb18c49603109652ffd69e68cfaf106aff33b Mon Sep 17 00:00:00 2001 From: alexis Date: Wed, 21 Jan 2026 11:07:50 +0800 Subject: [PATCH 06/17] fix: change fetch to apiFetch for auth apis --- src/composables/useAuth.js | 4 ++-- src/utils/api.js | 1 + src/utils/auth.js | 9 +++++---- vite.config.js | 1 - 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/composables/useAuth.js b/src/composables/useAuth.js index 441f1a2..f83c0c4 100644 --- a/src/composables/useAuth.js +++ b/src/composables/useAuth.js @@ -1,5 +1,5 @@ import { ref, onMounted, onUnmounted } from "vue"; -import { checkAuthentication as checkAuthUtil } from "../utils/api"; +import { apiFetch, checkAuthentication as checkAuthUtil } from "../utils/api"; import { getCookie } from "../utils/cookies"; export function useAuth() { @@ -19,7 +19,7 @@ export function useAuth() { const logout = async () => { try { - const response = await fetch("/api/auth/logout/", { + const response = await apiFetch("/api/auth/logout/", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/utils/api.js b/src/utils/api.js index 6322d0b..8b2d42c 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -3,6 +3,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ""; export async function apiFetch(path, options = {}) { + options.credentials = "include"; return fetch(`${API_BASE_URL}${path}`, options); } diff --git a/src/utils/auth.js b/src/utils/auth.js index 3aa7644..5930386 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -1,3 +1,4 @@ +import { apiFetch } from "./api"; import { getCookie } from "./cookies"; const OTP_STORAGE_KEY = "auth_otp"; @@ -34,7 +35,7 @@ function getTempTokenTimeoutSeconds() { * @returns {Promise<{otp: string, redirectUrl: string}>} */ export async function initiateAuth(action, turnstileToken) { - const response = await fetch("/api/auth/init/", { + const response = await apiFetch("/api/auth/init/", { method: "POST", headers: { "Content-Type": "application/json", @@ -76,7 +77,7 @@ export async function initiateAuth(action, turnstileToken) { * @returns {Promise} */ export async function verifyCallback(action, account, answerId) { - const response = await fetch("/api/auth/verify/", { + const response = await apiFetch("/api/auth/verify/", { method: "POST", headers: { "Content-Type": "application/json", @@ -115,7 +116,7 @@ export async function verifyCallback(action, account, answerId) { */ export async function setPassword(action, password) { const url = action === "signup" ? "/api/auth/signup/" : "/api/auth/password/"; - const response = await fetch(url, { + const response = await apiFetch(url, { method: "POST", headers: { "Content-Type": "application/json", @@ -141,7 +142,7 @@ export async function setPassword(action, password) { * @returns {Promise} */ export async function loginWithPassword(account, password, turnstileToken) { - const response = await fetch("/api/auth/login/", { + const response = await apiFetch("/api/auth/login/", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/vite.config.js b/vite.config.js index e684509..ab8b7d5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -9,7 +9,6 @@ export default defineConfig({ "/api": { target: "http://localhost:8000", // Your Django server changeOrigin: false, - // rewrite: (path) => path.replace(/^\/api/, ''), // Not needed if your Django URLs start with /api }, }, }, From 5b355d566c7f563f11b5dd00188ea889d702e135 Mon Sep 17 00:00:00 2001 From: alexis Date: Wed, 21 Jan 2026 11:24:53 +0800 Subject: [PATCH 07/17] refactor: move auth api wrappers from utils/ to composables/useAuth.js --- src/components/AuthInitiate.vue | 5 +- src/components/SetPasswordForm.vue | 3 +- src/composables/useAuth.js | 153 +++++++++++++++++++++++++- src/utils/api.js | 17 +-- src/utils/auth.js | 165 +---------------------------- src/views/AuthCallback.vue | 4 +- src/views/Login.vue | 10 +- 7 files changed, 167 insertions(+), 190 deletions(-) diff --git a/src/components/AuthInitiate.vue b/src/components/AuthInitiate.vue index 3d4ee8d..040a18b 100644 --- a/src/components/AuthInitiate.vue +++ b/src/components/AuthInitiate.vue @@ -109,9 +109,10 @@