diff --git a/frontend/src/features/courses/components/CourseDetailDrawer.tsx b/frontend/src/features/courses/components/CourseDetailDrawer.tsx index edb6aac..0c210bb 100644 --- a/frontend/src/features/courses/components/CourseDetailDrawer.tsx +++ b/frontend/src/features/courses/components/CourseDetailDrawer.tsx @@ -17,6 +17,7 @@ interface CourseDetailDrawerProps { listCourse?: Course | null isFavorite: boolean favoriteDisabled?: boolean + favoriteLoading?: boolean showFavorite?: boolean onToggleFavorite: () => void onClose: () => void @@ -27,6 +28,7 @@ export function CourseDetailDrawer({ listCourse = null, isFavorite, favoriteDisabled = false, + favoriteLoading = false, showFavorite = true, onToggleFavorite, onClose, @@ -78,7 +80,12 @@ export function CourseDetailDrawer({
{showFavorite ? ( - + ) : null} + onToggleFavorite(course.id)} + />
@@ -178,7 +186,9 @@ interface PlannerFavoritesPanelProps { catalogTo?: string onSetAssignment: (courseId: string, areaCode: string | null) => void onAddCourse: (courseId: string, areaCode: string | null) => void + onOpenCourse: (courseId: string) => void onToggleFavorite: (courseId: string) => void + isFavoriteSaving: (courseId: string) => boolean hiddenSlotIds: string[] onSelectTutorialSlot: (courseId: string, selectedSlotId: string) => void } @@ -200,7 +210,9 @@ export function PlannerFavoritesPanel({ catalogTo = ROUTES.catalog, onSetAssignment, onAddCourse, + onOpenCourse, onToggleFavorite, + isFavoriteSaving, hiddenSlotIds, onSelectTutorialSlot, }: PlannerFavoritesPanelProps) { @@ -259,7 +271,9 @@ export function PlannerFavoritesPanel({ activeSemesterLabel={activeSemesterLabel} hiddenSlotIds={hiddenSlotIds} onAddCourse={onAddCourse} + onOpenCourse={onOpenCourse} onToggleFavorite={onToggleFavorite} + isFavoriteSaving={isFavoriteSaving} onSelectTutorialSlot={onSelectTutorialSlot} /> diff --git a/frontend/src/features/planner/components/SemesterPlanner.tsx b/frontend/src/features/planner/components/SemesterPlanner.tsx index 0933714..bb4a434 100644 --- a/frontend/src/features/planner/components/SemesterPlanner.tsx +++ b/frontend/src/features/planner/components/SemesterPlanner.tsx @@ -68,7 +68,7 @@ export function SemesterPlanner({ const { isAuthenticated, token, user } = useAuth() const { t } = useTranslation() const { isOpen: isOnboardingOpen, activeStepId } = useOnboarding() - const { favoriteIds, toggleFavorite } = useFavorites() + const { favoriteIds, isFavoriteSaving, toggleFavorite } = useFavorites() const { completedCourses, isLoadingCompletedCourses, @@ -290,6 +290,13 @@ export function SemesterPlanner({ } } + function handleOpenFavoriteCourse(courseId: string): void { + setOpenCourseId(courseId) + if (isSmallViewport) { + setIsAddDrawerOpen(false) + } + } + function handleAddManualSlot(slot: ManualPlannerSlot): void { if (!plannedCourseIds.includes(slot.courseId)) { setPlannedCourseIds([...plannedCourseIds, slot.courseId]) @@ -452,7 +459,9 @@ export function SemesterPlanner({ maxVisibleCandidates={isPlannerMobileInterestedTour ? 2 : undefined} onSetAssignment={setAssignment} onAddCourse={handleInterestedCourseAdd} + onOpenCourse={handleOpenFavoriteCourse} onToggleFavorite={toggleFavorite} + isFavoriteSaving={isFavoriteSaving} hiddenSlotIds={displayHiddenSlotIds} onSelectTutorialSlot={handleTutorialSlotSelect} /> diff --git a/frontend/src/index.css b/frontend/src/index.css index 9314a78..c490d40 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -71,6 +71,88 @@ body { overflow: hidden; } +@keyframes favorite-save-pop { + 0% { + transform: scale(1); + } + 45% { + transform: scale(1.16) translateY(-1px); + } + 78% { + transform: scale(0.98); + } + 100% { + transform: scale(1); + } +} + +@keyframes favorite-save-burst { + 0% { + opacity: 0.55; + transform: translate(-50%, -50%) scale(0.55); + } + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(1.35); + } +} + +@keyframes favorite-remove-pop { + 0% { + transform: scale(1); + } + 45% { + transform: scale(0.86); + } + 100% { + transform: scale(1); + } +} + +@keyframes favorite-remove-ring { + 0% { + opacity: 0.35; + transform: translate(-50%, -50%) scale(0.98); + } + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.72); + } +} + +.favorite-add-pop { + animation: favorite-save-pop 320ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.favorite-remove-pop { + animation: favorite-remove-pop 260ms ease-out; +} + +.favorite-save-burst { + animation: favorite-save-burst 380ms ease-out forwards; + border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent); + border-radius: 9999px; + box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-primary) 8%, transparent); + height: 1.45rem; + left: 50%; + pointer-events: none; + position: absolute; + top: 50%; + width: 1.45rem; +} + +.favorite-remove-ring { + animation: favorite-remove-ring 300ms ease-out forwards; + border: 1px solid color-mix(in srgb, var(--color-fg-muted) 32%, transparent); + border-radius: 9999px; + height: 1.25rem; + left: 50%; + pointer-events: none; + position: absolute; + top: 50%; + width: 1.25rem; +} + .studyplanner-tour-active [data-app-topbar] { pointer-events: none; } diff --git a/frontend/src/shared/components/CourseCard.tsx b/frontend/src/shared/components/CourseCard.tsx index 345d74e..993270c 100644 --- a/frontend/src/shared/components/CourseCard.tsx +++ b/frontend/src/shared/components/CourseCard.tsx @@ -24,6 +24,7 @@ interface CourseCardProps { isActive?: boolean isCompleted?: boolean favoriteDisabled?: boolean + favoriteLoading?: boolean showFavorite?: boolean offeringStatus?: OfferingStatus // Overrides the raw course.termType so callers can align season tags with @@ -57,6 +58,7 @@ export function CourseCard({ isActive = false, isCompleted = false, favoriteDisabled = false, + favoriteLoading = false, showFavorite = true, offeringStatus = 'confirmed', seasonTermType, @@ -126,7 +128,12 @@ export function CourseCard({ }} onKeyDown={(event) => event.stopPropagation()} > - + ) : undefined } diff --git a/frontend/src/shared/components/FavStar.tsx b/frontend/src/shared/components/FavStar.tsx index 9606fe6..58d0396 100644 --- a/frontend/src/shared/components/FavStar.tsx +++ b/frontend/src/shared/components/FavStar.tsx @@ -4,12 +4,14 @@ import { BookmarkIcon } from './icons' interface FavStarProps { active: boolean disabled?: boolean + isLoading?: boolean onToggle: () => void } -export function FavStar({ active, disabled = false, onToggle }: FavStarProps) { +export function FavStar({ active, disabled = false, isLoading = false, onToggle }: FavStarProps) { const [isHovered, setIsHovered] = useState(false) - const showFilled = isHovered ? !active : active + const [feedback, setFeedback] = useState<{ key: number; type: 'add' | 'remove' } | null>(null) + const showFilled = isLoading ? active : isHovered ? !active : active return ( ) } diff --git a/frontend/src/shared/components/catClasses.ts b/frontend/src/shared/components/catClasses.ts index 8d74206..b28dc59 100644 --- a/frontend/src/shared/components/catClasses.ts +++ b/frontend/src/shared/components/catClasses.ts @@ -1,52 +1,52 @@ -import type { MasterCat } from '../../features/courses' - -/** Muted area tints — readable category hint without loud saturation. */ -export const CAT_BADGE_CLASSES: Record = { - TECH: 'text-cat-tech/45 border-cat-tech/18 bg-cat-tech/5', - THEO: 'text-cat-theo/42 border-cat-theo/16 bg-cat-theo/4', - PRAK: 'text-cat-prak/45 border-cat-prak/18 bg-cat-prak/5', - INFO: 'text-cat-info/55 border-cat-info/25 bg-cat-info/10 dark:text-[#c4a8e8] dark:border-[#9b7cc4]/45 dark:bg-cat-info/20', - BASIS: 'text-cat-basis/42 border-cat-basis/16 bg-cat-basis/4', -} - -/** Matches tag intensity — soft fills for credited ECTS in the auto-assign panel. */ -export const CAT_PROGRESS_CREDITED_CLASSES: Record = { - TECH: 'bg-cat-tech/18', - THEO: 'bg-cat-theo/15', - PRAK: 'bg-cat-prak/18', - INFO: 'bg-cat-info/15', - BASIS: 'bg-cat-basis/15', -} - -export const CAT_PROGRESS_DOT_CLASSES: Record = { - TECH: 'bg-cat-tech/40', - THEO: 'bg-cat-theo/38', - PRAK: 'bg-cat-prak/40', - INFO: 'bg-cat-info/38', - BASIS: 'bg-cat-basis/38', -} - -export function catProgressPlannedStyle(masterCat: MasterCat | null): { - backgroundColor: string - backgroundImage: string - opacity: number -} { - const baseColor = masterCat === 'TECH' - ? 'var(--color-cat-tech)' - : masterCat === 'THEO' - ? 'var(--color-cat-theo)' - : masterCat === 'PRAK' - ? 'var(--color-cat-prak)' - : masterCat === 'INFO' - ? 'var(--color-cat-info)' - : masterCat === 'BASIS' - ? 'var(--color-cat-basis)' - : 'var(--color-border)' - return { - backgroundColor: baseColor, - backgroundImage: - 'repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.5) 3px, rgba(255,255,255,0.5) 6px)', - opacity: 0.22, - } -} - \ No newline at end of file +import type { MasterCat } from '../../features/courses' + +/** Muted area tints — readable category hint without loud saturation. */ +export const CAT_BADGE_CLASSES: Record = { + TECH: 'border-[#B8D9E6] bg-[#EEF8FB] text-[#315F73] dark:border-[#4A6470] dark:bg-[#253239] dark:text-[#A8C7D3]', + THEO: 'border-[#EBC3D6] bg-[#FCF0F5] text-[#8A4968] dark:border-[#714B5E] dark:bg-[#382932] dark:text-[#D8A9C0]', + PRAK: 'border-[#B7E1DA] bg-[#EFF9F6] text-[#3D6E66] dark:border-[#486B65] dark:bg-[#263633] dark:text-[#A8D2CA]', + INFO: 'border-[#D3C4E6] bg-[#F6F1FB] text-[#654F83] dark:border-[#5E526F] dark:bg-[#302A39] dark:text-[#C4B4D9]', + BASIS: 'border-[#E8C7C4] bg-[#FCF1EF] text-[#8A504B] dark:border-[#704F4B] dark:bg-[#392B29] dark:text-[#D9ABA6]', +} + +/** Matches tag intensity — soft fills for credited ECTS in the auto-assign panel. */ +export const CAT_PROGRESS_CREDITED_CLASSES: Record = { + TECH: 'bg-cat-tech/18', + THEO: 'bg-cat-theo/15', + PRAK: 'bg-cat-prak/18', + INFO: 'bg-cat-info/15', + BASIS: 'bg-cat-basis/15', +} + +export const CAT_PROGRESS_DOT_CLASSES: Record = { + TECH: 'bg-cat-tech/40', + THEO: 'bg-cat-theo/38', + PRAK: 'bg-cat-prak/40', + INFO: 'bg-cat-info/38', + BASIS: 'bg-cat-basis/38', +} + +export function catProgressPlannedStyle(masterCat: MasterCat | null): { + backgroundColor: string + backgroundImage: string + opacity: number +} { + const baseColor = masterCat === 'TECH' + ? 'var(--color-cat-tech)' + : masterCat === 'THEO' + ? 'var(--color-cat-theo)' + : masterCat === 'PRAK' + ? 'var(--color-cat-prak)' + : masterCat === 'INFO' + ? 'var(--color-cat-info)' + : masterCat === 'BASIS' + ? 'var(--color-cat-basis)' + : 'var(--color-border)' + return { + backgroundColor: baseColor, + backgroundImage: + 'repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.5) 3px, rgba(255,255,255,0.5) 6px)', + opacity: 0.22, + } +} + diff --git a/frontend/tests/favorites/favoriteIds.test.ts b/frontend/tests/favorites/favoriteIds.test.ts new file mode 100644 index 0000000..9a664da --- /dev/null +++ b/frontend/tests/favorites/favoriteIds.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { toggleFavoriteId, updateSavingFavoriteIds } from '../../src/features/favorites/utils/favoriteIds.ts' + +test('toggleFavoriteId adds and removes one course without mutating the input', () => { + const favoriteIds = ['course-a'] + + const withAddedCourse = toggleFavoriteId(favoriteIds, 'course-b') + const withRemovedCourse = toggleFavoriteId(favoriteIds, 'course-a') + + assert.deepEqual(withAddedCourse, ['course-a', 'course-b']) + assert.deepEqual(withRemovedCourse, []) + assert.deepEqual(favoriteIds, ['course-a']) +}) + +test('updateSavingFavoriteIds tracks one saving entry per course', () => { + const savingFavoriteIds = updateSavingFavoriteIds(['course-a'], 'course-a', true) + + assert.deepEqual(savingFavoriteIds, ['course-a']) + assert.deepEqual(updateSavingFavoriteIds(savingFavoriteIds, 'course-b', true), ['course-a', 'course-b']) + assert.deepEqual(updateSavingFavoriteIds(savingFavoriteIds, 'course-a', false), []) +})