Skip to content

Commit

Permalink
feat: add favourites functionality (#352)
Browse files Browse the repository at this point in the history
  • Loading branch information
ImJustChew authored Jun 17, 2024
2 parents aa9c88b + 04471a9 commit 7a73ebe
Show file tree
Hide file tree
Showing 3 changed files with 267 additions and 9 deletions.
49 changes: 47 additions & 2 deletions src/app/[lang]/(mods-pages)/courses/CourseSearchContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { renderTimetableSlot } from '@/helpers/timetable_course';
import { ScrollArea } from "@/components/ui/scroll-area"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { InstantSearchNext } from 'react-instantsearch-nextjs';
import { Calendar, FilterIcon, SearchIcon } from "lucide-react";
import { Calendar, FilterIcon, Heart, SearchIcon } from "lucide-react";
import { PoweredBy, SearchBox } from 'react-instantsearch';

const searchClient = algoliasearch(process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!, process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY!);
Expand All @@ -40,6 +40,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import TimetableCourseList, { DownloadTimetableDialogDynamic, ShareSyncTimetableDialogDynamic } from '@/components/Timetable/TimetableCourseList';
import ClearAllButton from './ClearAllButton';
import useDictionary from '@/dictionaries/useDictionary';
import FavouritesCourseList from './FavouritesCourseList';

const SemesterSelector = () => {
// refine semester for semester selector
Expand Down Expand Up @@ -188,7 +189,41 @@ const CourseSearchContainer = () => {
</DrawerTrigger>
<DrawerContent>
<ScrollArea className="w-full max-h-[80vh] overflow-auto p-2">
<TimetableWithSemester />
<Tabs defaultValue="timetable">
<TabsList className="w-full justify-around">
<TabsTrigger value="timetable" className="flex-1">
{dict.course.details.timetable}
</TabsTrigger>
<TabsTrigger value="list" className="flex-1">
{dict.course.details.course_list}
</TabsTrigger>
<TabsTrigger value="favourites" className="flex-1">
已收藏課程
</TabsTrigger>
</TabsList>
<TabsContent value="timetable" className="h-full">
<ScrollArea className="w-full h-[calc(100vh-12.5rem)] overflow-auto border rounded-2xl">
<div className="p-4 h-full">
<TimetableWithSemester />
<TimetableBottomBar />
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="list">
<ScrollArea className="w-full h-[calc(100vh-12.5rem)] overflow-auto border rounded-2xl">
<div className="p-4 h-full">
<TimetableCourseListWithSemester />
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="favourites">
<ScrollArea className="w-full h-[calc(100vh-12.5rem)] overflow-auto border rounded-2xl">
<div className="p-4 h-full">
<FavouritesCourseList />
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</ScrollArea>
</DrawerContent>
</Drawer>
Expand All @@ -215,6 +250,9 @@ const CourseSearchContainer = () => {
<TabsTrigger value="list" className="flex-1">
{dict.course.details.course_list}
</TabsTrigger>
<TabsTrigger value="favourites" className="flex-1">
已收藏課程
</TabsTrigger>
</TabsList>
<TabsContent value="timetable" className="h-full">
<ScrollArea className="w-full h-[calc(100vh-12.5rem)] overflow-auto border rounded-2xl">
Expand All @@ -231,6 +269,13 @@ const CourseSearchContainer = () => {
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="favourites">
<ScrollArea className="w-full h-[calc(100vh-12.5rem)] overflow-auto border rounded-2xl">
<div className="p-4 h-full">
<FavouritesCourseList />
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</ResizablePanel>
</ResizablePanelGroup>
Expand Down
190 changes: 190 additions & 0 deletions src/app/[lang]/(mods-pages)/courses/FavouritesCourseList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { Search, Trash, AlertTriangle, Copy, GripVertical, Loader2, Plus, Heart, Minus } from 'lucide-react';
import { useSettings } from '@/hooks/contexts/settings';
import useUserTimetable from '@/hooks/contexts/useUserTimetable';
import { useRouter, useSearchParams } from 'next/navigation';
import useDictionary from '@/dictionaries/useDictionary';
import { useMemo } from 'react';
import { hasConflictingTimeslots, hasSameCourse, hasTimes } from '@/helpers/courses';
import { MinimalCourse, RawCourseID } from '@/types/courses';
import dynamic from 'next/dynamic';
import { Button } from '@/components/ui/button';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
TouchSensor,
} from '@dnd-kit/core';
import {
arrayMove,
rectSwappingStrategy,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

import {
restrictToVerticalAxis,
restrictToWindowEdges,
} from '@dnd-kit/modifiers';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import Compact from '@uiw/react-color-compact';
import { Separator } from '@/components/ui/separator';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { TimetableItemDrawer } from '@/components/Timetable/TimetableItemDrawer';
import { useQuery } from '@tanstack/react-query';
import supabase from '@/config/supabase';
import {CourseDefinition} from '@/config/supabase';
import { useLocalStorage } from 'usehooks-ts';

const TimetableCourseListItem = ({
course,
}: {
course: MinimalCourse,
}) => {
const { language } = useSettings();
const router = useRouter();
const searchParams = useSearchParams();

const {
addCourse,
deleteCourse,
isCourseSelected
} = useUserTimetable();

const [favourites, setFavourites] = useLocalStorage<string[]>('course_favourites', []);

const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: course.raw_id });


const style = {
transform: CSS.Translate.toString(transform),
transition,
};

const unfavourite = () => {
setFavourites(favourites.filter(fav => fav != course.raw_id));
}

return <div className="flex flex-row gap-2 items-center max-w-3xl" ref={setNodeRef} style={style} >
<GripVertical className="w-4 h-4 text-gray-400" {...attributes} {...listeners} />
<div className="flex flex-col flex-1" onClick={() => router.push(`/${language}/courses/${course.raw_id}?${searchParams.toString()}`,)}>
<span className="text-sm">{course.department} {course.course}-{course.class} {course.name_zh} - {course.teacher_zh.join(',')}</span>
<span className="text-xs">{course.name_en}</span>
<div className="mt-1">
{course.venues?.map((venue, index) => {
const time = course.times![index];
return <div key={index} className="flex flex-row items-center space-x-2 text-gray-400">
<span className="text-xs">{venue}</span>
{hasTimes(course as MinimalCourse) ? <span className="text-xs">{time}</span> : <span className="text-xs text-red-500">缺時間</span>}
</div>
}) || <span className="text-gray-400 text-xs">No Venue</span>}
</div>
</div>
<div className="flex flex-col gap-1 items-start">
<div className="flex flex-row items-center space-x-1">
<span className="text-base">{course.credits}</span>
<span className="text-xs text-gray-400">學分</span>
</div>
<div className='flex flex-row'>
<Button className='rounded-r-none' variant="outline" size="icon" onClick={() => unfavourite()}>
<Heart className="w-4 h-4 fill-red-500 text-red-500" />
</Button>
{isCourseSelected(course.raw_id) ?
<Button className='rounded-l-none' variant="destructive" size="icon" onClick={() => deleteCourse(course.raw_id)}>
<Minus className="w-4 h-4" />
</Button>:
<Button className='rounded-l-none' variant="outline" size="icon" onClick={() => addCourse(course.raw_id)}>
<Plus className="w-4 h-4" />
</Button>}
</div>
</div>
</div>
}

export const FavouritesCourseList = ({
}: {
}) => {
const { language } = useSettings();
const dict = useDictionary();
const router = useRouter();
const [favourites, setFavourites] = useLocalStorage<string[]>('course_favourites', []);

const { data: courses = [], error } = useQuery({
queryKey: ['courses', [...favourites].sort()],
queryFn: async () => {
const { data = [], error } = await supabase.from('courses').select('*').in('raw_id', [...favourites].sort());
if (error) throw error;
if (!data) throw new Error('No data');
return data as CourseDefinition[];
},
});

const displayCourseData = useMemo(() => courses.sort((a, b) => favourites.indexOf(a.raw_id) - favourites.indexOf(b.raw_id)), [courses, favourites]);

const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor),
);

function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) return;
if (active.id !== over.id) {
setFavourites((favourites) => {
const oldIndex = favourites.indexOf(active.id as string);
const newIndex = favourites.indexOf(over.id as string);

return arrayMove(favourites, oldIndex, newIndex);
});
// const courseCopy = [...favourites];
// const oldIndex = courseCopy.indexOf(active.id as string);
// const newIndex = courseCopy.indexOf(over.id as string);
// const newCourseCopy = arrayMove(courseCopy, oldIndex, newIndex);
// setFavourites(newCourseCopy);
}
}

return <div className='flex flex-col gap-2'>
<div className={`flex flex-col gap-4 px-4 flex-wrap`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis]}
>
<SortableContext
items={displayCourseData.map(course => course.raw_id)}
strategy={verticalListSortingStrategy}
>
{displayCourseData.map((course, index) => (
<TimetableCourseListItem
key={index}
course={course as MinimalCourse}
/>
))}
</SortableContext>
</DndContext>
{displayCourseData.length == 0 && (
<div className="flex flex-col items-center space-y-4">
<span className="text-lg font-semibold text-gray-400">{"No Favourites Added (yet)"}</span>
</div>
)}
</div>
</div>
}
export default FavouritesCourseList;
37 changes: 30 additions & 7 deletions src/components/Courses/SelectCourseButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,47 @@ import { useSettings } from "@/hooks/contexts/settings"
import useUserTimetable from "@/hooks/contexts/useUserTimetable";
import { RawCourseID, Semester } from "@/types/courses";
import { useMemo } from "react";
import { Minus, Plus } from "lucide-react";
import { Heart, Minus, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLocalStorage } from "usehooks-ts";
import { toast } from "../ui/use-toast";
import { lastSemester } from "@/const/semester";

const SelectCourseButton = ({ courseId }: { courseId: RawCourseID }) => {
const { isCourseSelected, addCourse, deleteCourse } = useUserTimetable();
const [ favourites, setFavourites ] = useLocalStorage<string[]>('course_favourites', []);

const dict = useDictionary();
const courseSemester = courseId.slice(0, 5) as Semester;

if(isCourseSelected(courseId)) return <Button
const isFavouritable = courseSemester == lastSemester.id;

const isInFavourites = favourites.includes(courseId);
const handleToggleFavourite = () => {
if(isInFavourites){
setFavourites(favourites.filter(fav => fav != courseId));
} else {
setFavourites([...favourites, courseId]);
}
}

return <div className="flex flex-row gap-2 red-500">
{isFavouritable && <Button variant='outline' size='icon' onClick={handleToggleFavourite}>
{isInFavourites ? <Heart className="text-red-500 fill-red-500 w-4 h-4"/> :<Heart className="w-4 h-4" /> }
</Button>}
{isCourseSelected(courseId) ?
<Button
variant={'destructive'}
onClick={() => deleteCourse(courseId)}
>
<Minus/> {dict.course.item.remove_from_semester}
</Button>
else return <Button
<Minus className="w-4 h-4"/> {dict.course.item.remove_from_semester}
</Button>:
<Button
onClick={() => addCourse(courseId)}
>
<Plus/> {dict.course.item.add_to_semester}
</Button>
<Plus className="w-4 h-4"/> {dict.course.item.add_to_semester}
</Button> }
</div>
}

export default SelectCourseButton;

0 comments on commit 7a73ebe

Please sign in to comment.