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}
@@ -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), [])
+})