diff --git a/database/indexes/courses.sql b/database/indexes/courses.sql index 336a5ed5..55ae0cbc 100644 --- a/database/indexes/courses.sql +++ b/database/indexes/courses.sql @@ -6,6 +6,7 @@ create index courses_teacher_en ON courses USING pgroonga(teacher_en); create index courses_department ON courses USING pgroonga(department); create index courses_course ON courses USING pgroonga(course); create index courses_raw_id ON courses USING pgroonga(raw_id); +create index courses_keywords ON course_syllabus USING pgroonga(keywords); CREATE OR REPLACE FUNCTION search_courses(keyword text) RETURNS SETOF courses AS @@ -26,4 +27,27 @@ BEGIN END IF; END $func$ +LANGUAGE plpgsql; + +DROP FUNCTION search_courses_with_syllabus; + +CREATE OR REPLACE FUNCTION search_courses_with_syllabus(keyword text) +RETURNS SETOF courses_with_syllabus AS +$func$ +BEGIN + IF trim(keyword) = '' THEN + -- Keyword is blank, return all courses + RETURN QUERY SELECT * FROM courses_with_syllabus; + ELSE + -- Keyword is not blank, perform the search + RETURN QUERY SELECT * FROM courses_with_syllabus + WHERE name_zh &@~ keyword + OR teacher_zh &@~ keyword + OR venues &@~ keyword + OR name_en &@~ keyword + OR teacher_en &@~ keyword + OR raw_id &@ keyword; + END IF; +END +$func$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/database/views/courses_with_syllabus.sql b/database/views/courses_with_syllabus.sql new file mode 100644 index 00000000..861e4264 --- /dev/null +++ b/database/views/courses_with_syllabus.sql @@ -0,0 +1,7 @@ +CREATE OR REPLACE VIEW courses_with_syllabus AS +SELECT + c.*, + cs.brief, + cs.keywords +FROM courses c +LEFT JOIN course_syllabus cs ON c.raw_id = cs.raw_id; diff --git a/src/app/[lang]/courses/[courseId]/DownloadSyllabus.tsx b/src/app/[lang]/courses/[courseId]/DownloadSyllabus.tsx new file mode 100644 index 00000000..4ce15365 --- /dev/null +++ b/src/app/[lang]/courses/[courseId]/DownloadSyllabus.tsx @@ -0,0 +1,22 @@ +'use client'; +import supabase from "@/config/supabase"; +import { RawCourseID } from "@/types/courses"; +import { Button } from "@mui/joy"; +import { DownloadCloud } from "react-feather"; + +const DownloadSyllabus = ({ courseId }: { courseId: RawCourseID }) => { + const handleDownload = async () => { + + const fileURL = `${process.env.NEXT_PUBLIC_SUPABASE_URL}storage/v1/object/public/syllabus/${encodeURIComponent(courseId)}.pdf`; + const link = document.createElement('a') + link.href = fileURL + link.setAttribute('download', courseId+'.pdf') + document.body.appendChild(link) + link.click() + link.remove() + } + + return +} + +export default DownloadSyllabus; \ No newline at end of file diff --git a/src/app/[lang]/courses/[courseId]/page.tsx b/src/app/[lang]/courses/[courseId]/page.tsx index 8991d8f2..c2562a94 100644 --- a/src/app/[lang]/courses/[courseId]/page.tsx +++ b/src/app/[lang]/courses/[courseId]/page.tsx @@ -1,11 +1,11 @@ import Fade from "@/components/Animation/Fade"; import { getDictionary } from "@/dictionaries/dictionaries"; -import {getCourse, getCoursePTTReview} from '@/lib/course'; +import {getCourse, getCoursePTTReview, getCourseWithSyllabus} from '@/lib/course'; import { LangProps } from "@/types/pages"; import {Accordion, AccordionDetails, AccordionGroup, AccordionSummary, Alert, Chip, Divider, Button} from '@mui/joy'; import { format } from "date-fns"; import { ResolvingMetadata } from "next"; -import {AlertTriangle, Minus, Plus} from 'react-feather'; +import {AlertTriangle, DownloadCloud, Minus, Plus} from 'react-feather'; import { redirect } from 'next/navigation' import CourseTagList from "@/components/Courses/CourseTagsList"; import {useSettings} from '@/hooks/contexts/settings'; @@ -14,6 +14,7 @@ import SelectCourseButton from '@/components/Courses/SelectCourseButton'; import { createTimetableFromCourses } from "@/helpers/timetable"; import Timetable from "@/components/Timetable/Timetable"; import { MinimalCourse } from "@/types/courses"; +import DownloadSyllabus from "./DownloadSyllabus"; type PageProps = { params: { courseId? : string } @@ -29,7 +30,7 @@ export async function generateMetadata({ params }: PageProps, parent: ResolvingM const CourseDetailPage = async ({ params }: PageProps & LangProps) => { const courseId = decodeURI(params.courseId as string); - const course = await getCourse(courseId); + const course = await getCourseWithSyllabus(courseId); const reviews = await getCoursePTTReview(courseId); const dict = await getDictionary(params.lang); @@ -71,9 +72,16 @@ const CourseDetailPage = async ({ params }: PageProps & LangProps) => {
+
+

Brief

+

{course.course_syllabus.brief}

+

{dict.course.details.description}

-

{course?.note}

+

+ {course.course_syllabus.content ?? <> + + }

{course?.prerequisites &&

儅修

@@ -101,9 +109,13 @@ const CourseDetailPage = async ({ params }: PageProps & LangProps) => {
}
+
+

Note

+

{course.note}

+

{dict.course.details.restrictions}

-

{course.restrictions}

+

{course.restrictions ?? "無"}

{dict.course.details.compulsory}

diff --git a/src/app/[lang]/courses/page.tsx b/src/app/[lang]/courses/page.tsx index 142fdc51..010cc484 100644 --- a/src/app/[lang]/courses/page.tsx +++ b/src/app/[lang]/courses/page.tsx @@ -1,7 +1,7 @@ 'use client';; import CourseListItem from "@/components/Courses/CourseListItem"; import InputControl from "@/components/FormComponents/InputControl"; -import supabase, { CourseDefinition } from "@/config/supabase"; +import supabase, {CourseDefinition, CourseSyllabusView} from '@/config/supabase'; import { Button, CircularProgress, Divider, Drawer, IconButton, LinearProgress, Stack } from "@mui/joy"; import { NextPage } from "next"; import { useEffect, useState, Fragment, useRef, use, useMemo } from "react"; @@ -56,7 +56,7 @@ const emptyFilters: RefineControlFormTypes = { const CoursePage: NextPage = () => { const dict = useDictionary(); - const [courses, setCourses] = useState([]); + const [courses, setCourses] = useState([]); const [loading, setLoading] = useState(true); const [totalCount, setTotalCount] = useState(0); const [headIndex, setHeadIndex] = useState(0); @@ -134,7 +134,7 @@ const CoursePage: NextPage = () => { setLoading(true); //Query for courses try { - let temp = supabase.rpc('search_courses', { + let temp = supabase.rpc('search_courses_with_syllabus', { keyword: filters.textSearch, }, { count: 'exact' }) if (filters.level.length) @@ -196,7 +196,7 @@ const CoursePage: NextPage = () => { if (error) console.error(error); else { console.log(courses) - setCourses(courses as CourseDefinition[]); + setCourses(courses as CourseSyllabusView[]); setHeadIndex(index); } } diff --git a/src/components/Courses/CourseListItem.tsx b/src/components/Courses/CourseListItem.tsx index 119261bc..3cb14fea 100644 --- a/src/components/Courses/CourseListItem.tsx +++ b/src/components/Courses/CourseListItem.tsx @@ -1,5 +1,5 @@ 'use client'; -import { CourseDefinition } from '@/config/supabase'; +import {CourseDefinition, CourseSyllabusView} from '@/config/supabase'; import useDictionary from '@/dictionaries/useDictionary'; import { useSettings } from '@/hooks/contexts/settings'; import { Button, Tooltip } from '@mui/joy'; @@ -9,8 +9,7 @@ import Link from 'next/link'; import CourseTagList from './CourseTagsList'; import SelectCourseButton from './SelectCourseButton'; - -const CourseListItem: FC<{ course: CourseDefinition }> = ({ course }) => { +const CourseListItem: FC<{ course: CourseSyllabusView }> = ({ course }) => { const dict = useDictionary(); return
@@ -21,6 +20,7 @@ const CourseListItem: FC<{ course: CourseDefinition }> = ({ course }) => {

{course.name_en} - {(course.teacher_en ?? []).join(',')}

+

{course.brief}

{course.restrictions}

{course.note}

{course.prerequisites && @@ -40,8 +40,8 @@ const CourseListItem: FC<{ course: CourseDefinition }> = ({ course }) => {

No Venues

}
- - + +
diff --git a/src/config/supabase.ts b/src/config/supabase.ts index 86efe8ec..df993178 100644 --- a/src/config/supabase.ts +++ b/src/config/supabase.ts @@ -4,6 +4,9 @@ import { createClient } from "@supabase/supabase-js"; const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "", process.env.NEXT_PUBLIC_SUPABASE_KEY ?? ""); export type CourseDefinition = Database['public']['Tables']['courses']['Row']; +export type CourseSyllabusDefinition = Database['public']['Tables']['course_syllabus']['Row']; +export type CourseJoinWithSyllabus = CourseDefinition & { course_syllabus: CourseSyllabusDefinition }; +export type CourseSyllabusView = Database['public']['Views']['courses_with_syllabus']['Row']; export type AlertDefinition = Database['public']['Tables']['alerts']['Row']; export type BusScheduleDefinition = Database['public']['Tables']['bus_schedule']['Row']; export type CdsCourseDefinition = Database['public']['Tables']['cds_courses']['Row']; diff --git a/src/hooks/useUserTimetable.tsx b/src/hooks/useUserTimetable.tsx index 74540480..a3329a9f 100644 --- a/src/hooks/useUserTimetable.tsx +++ b/src/hooks/useUserTimetable.tsx @@ -10,7 +10,7 @@ const useUserTimetable = (loadCourse = true) => { const { courses, timetableTheme, setCourses } = useSettings(); const { data: allCourseData = [], error, isLoading } = useSWR(['courses', courses], async ([table, courseCodes]) => { - const { data = [], error } = await supabase.from('courses').select("*").in('raw_id', courseCodes); + const { data = [], error } = await supabase.from('courses_with_syllabus').select("*").in('raw_id', courseCodes); if(error) throw error; if(!data) throw new Error('No data'); return data; diff --git a/src/lib/course.ts b/src/lib/course.ts index 0e027919..eaac1486 100644 --- a/src/lib/course.ts +++ b/src/lib/course.ts @@ -1,5 +1,25 @@ import supabase from '@/config/supabase'; import { parse } from 'node-html-parser'; +import {CourseJoinWithSyllabus} from '@/config/supabase'; + + +export const getCourseWithSyllabus = async (courseId: string) => { + const { data, error } = await supabase + .from('courses') + .select(` + *, + course_syllabus ( + * + ) + `) + .eq('raw_id', courseId); + if(error) { + console.error(error) + return null; + } + else return data![0] as unknown as CourseJoinWithSyllabus; +} + export const getCourse = async (courseId: string) => { const { data, error } = await supabase.from('courses').select('*').eq('raw_id', courseId); @@ -10,6 +30,19 @@ export const getCourse = async (courseId: string) => { else return data![0]; } + +export const getMinimalCourse = async (courseId: string) => { + const { data, error } = await supabase + .from('courses') + .select('raw_id, name_zh, name_en, department, course, class, credits, venues, times, teacher_zh, language') + .eq('raw_id', courseId); + if(error) { + console.error(error) + return null; + } + else return data![0]; +} + export const getCoursePTTReview = async (courseId: string) => { const course = await getCourse(courseId); //TODO: use better search to find the posts diff --git a/src/types/supabase.ts b/src/types/supabase.ts index 273cadc2..3d435fd1 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -604,6 +604,12 @@ export interface Database { columns: ["raw_id"] referencedRelation: "courses" referencedColumns: ["raw_id"] + }, + { + foreignKeyName: "course_syllabus_raw_id_fkey" + columns: ["raw_id"] + referencedRelation: "courses_with_syllabus" + referencedColumns: ["raw_id"] } ] } @@ -783,6 +789,51 @@ export interface Database { } } Views: { + courses_with_syllabus: { + Row: { + brief: string | null + capacity: number | null + class: string | null + closed_mark: string | null + compulsory_for: string[] | null + content: string | null + course: string | null + credits: number | null + cross_discipline: string[] | null + department: string | null + elective_for: string[] | null + first_specialization: string[] | null + ge_target: string | null + ge_type: string | null + has_file: boolean | null + keywords: string | null + language: string | null + name_en: string | null + name_zh: string | null + no_extra_selection: boolean | null + note: string | null + prerequisites: string | null + raw_1_2_specialization: string | null + raw_compulsory_elective: string | null + raw_cross_discipline: string | null + raw_extra_selection: string | null + raw_id: string | null + raw_teacher_en: string | null + raw_teacher_zh: string | null + raw_time: string | null + raw_venue: string | null + reserve: number | null + restrictions: string | null + second_specialization: string[] | null + semester: string | null + tags: string[] | null + teacher_en: string[] | null + teacher_zh: string[] | null + times: string[] | null + venues: string[] | null + } + Relationships: [] + } distinct_classes: { Row: { class: string | null @@ -1415,6 +1466,53 @@ export interface Database { venues: string[] }[] } + search_courses_with_syllabus: { + Args: { + keyword: string + } + Returns: { + brief: string | null + capacity: number | null + class: string | null + closed_mark: string | null + compulsory_for: string[] | null + content: string | null + course: string | null + credits: number | null + cross_discipline: string[] | null + department: string | null + elective_for: string[] | null + first_specialization: string[] | null + ge_target: string | null + ge_type: string | null + has_file: boolean | null + keywords: string | null + language: string | null + name_en: string | null + name_zh: string | null + no_extra_selection: boolean | null + note: string | null + prerequisites: string | null + raw_1_2_specialization: string | null + raw_compulsory_elective: string | null + raw_cross_discipline: string | null + raw_extra_selection: string | null + raw_id: string | null + raw_teacher_en: string | null + raw_teacher_zh: string | null + raw_time: string | null + raw_venue: string | null + reserve: number | null + restrictions: string | null + second_specialization: string[] | null + semester: string | null + tags: string[] | null + teacher_en: string[] | null + teacher_zh: string[] | null + times: string[] | null + venues: string[] | null + }[] + } set_limit: { Args: { "": number