From 5b163a85cf466f42007810ef694812edc406929b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Emre=20S=C3=B6zbilir?=
Date: Tue, 7 Jul 2026 21:36:34 +0200
Subject: [PATCH 1/3] Improve course save and planner detail UX
---
.../courses/components/CourseDetailDrawer.tsx | 9 +-
.../features/courses/components/Overview.tsx | 8 +-
.../features/favorites/FavoritesContext.ts | 1 +
.../components/FavoritesProvider.tsx | 17 ++-
.../features/favorites/utils/favoriteIds.ts | 18 +++
.../components/PlannerFavoritesPanel.tsx | 24 +++-
.../planner/components/SemesterPlanner.tsx | 11 +-
frontend/src/shared/components/CourseCard.tsx | 9 +-
frontend/src/shared/components/FavStar.tsx | 14 ++-
frontend/src/shared/components/catClasses.ts | 104 +++++++++---------
frontend/tests/favorites/favoriteIds.test.ts | 23 ++++
11 files changed, 164 insertions(+), 74 deletions(-)
create mode 100644 frontend/src/features/favorites/utils/favoriteIds.ts
create mode 100644 frontend/tests/favorites/favoriteIds.test.ts
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}
course.id === openCourseId) ?? null}
isFavorite={isFavorite(openCourseId)}
- favoriteDisabled={isLoadingFavorites || isSavingFavorites}
+ favoriteDisabled={isLoadingFavorites}
+ favoriteLoading={isFavoriteSaving(openCourseId)}
showFavorite={canShowFavorites}
onToggleFavorite={() => toggleFavorite(openCourseId)}
onClose={() => navigate(catalogBasePath)}
diff --git a/frontend/src/features/favorites/FavoritesContext.ts b/frontend/src/features/favorites/FavoritesContext.ts
index ce818f8..9e2e192 100644
--- a/frontend/src/features/favorites/FavoritesContext.ts
+++ b/frontend/src/features/favorites/FavoritesContext.ts
@@ -6,6 +6,7 @@ export interface FavoritesContextValue {
isSavingFavorites: boolean
favoritesError: string | null
isFavorite: (courseId: string) => boolean
+ isFavoriteSaving: (courseId: string) => boolean
toggleFavorite: (courseId: string) => void
}
diff --git a/frontend/src/features/favorites/components/FavoritesProvider.tsx b/frontend/src/features/favorites/components/FavoritesProvider.tsx
index e579749..b94cff4 100644
--- a/frontend/src/features/favorites/components/FavoritesProvider.tsx
+++ b/frontend/src/features/favorites/components/FavoritesProvider.tsx
@@ -5,6 +5,7 @@ import { useAuth } from '../../auth'
import { addCourseToCurrentSemesterPlan } from '../../planner/utils/addCourseToCurrentSemesterPlan.ts'
import { fetchFavoriteCourseIds, saveFavoriteCourseIds } from '../api'
import { FavoritesContext } from '../FavoritesContext'
+import { toggleFavoriteId, updateSavingFavoriteIds } from '../utils/favoriteIds.ts'
interface FavoritesProviderProps {
children: ReactNode
@@ -25,8 +26,9 @@ export function FavoritesProvider({ children }: FavoritesProviderProps): JSX.Ele
const userCacheKey = user?.username ?? 'anonymous'
const [favoriteIds, setFavoriteIds] = useState([])
const [isLoadingFavorites, setIsLoadingFavorites] = useState(false)
- const [isSavingFavorites, setIsSavingFavorites] = useState(false)
+ const [savingFavoriteCourseIds, setSavingFavoriteCourseIds] = useState([])
const [favoritesError, setFavoritesError] = useState(null)
+ const isSavingFavorites = savingFavoriteCourseIds.length > 0
useEffect(() => {
let isActive = true
@@ -69,21 +71,23 @@ export function FavoritesProvider({ children }: FavoritesProviderProps): JSX.Ele
}, [token])
const isFavorite = (courseId: string): boolean => favoriteIds.includes(courseId)
+ const isFavoriteSaving = (courseId: string): boolean => savingFavoriteCourseIds.includes(courseId)
const toggleFavorite = (courseId: string): void => {
if (!token) {
setFavoritesError('Sign in to save interested courses across devices.')
return
}
+ if (savingFavoriteCourseIds.includes(courseId)) {
+ return
+ }
const previousFavoriteIds = favoriteIds
- const nextFavoriteIds = favoriteIds.includes(courseId)
- ? favoriteIds.filter((id) => id !== courseId)
- : [...favoriteIds, courseId]
+ const nextFavoriteIds = toggleFavoriteId(favoriteIds, courseId)
setFavoriteIds(nextFavoriteIds)
setFavoritesError(null)
- setIsSavingFavorites(true)
+ setSavingFavoriteCourseIds((current) => updateSavingFavoriteIds(current, courseId, true))
const isAddingFavorite = nextFavoriteIds.length > previousFavoriteIds.length
@@ -103,7 +107,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps): JSX.Ele
setFavoritesError(normalizeErrorMessage(error))
})
.finally(() => {
- setIsSavingFavorites(false)
+ setSavingFavoriteCourseIds((current) => updateSavingFavoriteIds(current, courseId, false))
})
}
@@ -115,6 +119,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps): JSX.Ele
isSavingFavorites,
favoritesError,
isFavorite,
+ isFavoriteSaving,
toggleFavorite,
}}
>
diff --git a/frontend/src/features/favorites/utils/favoriteIds.ts b/frontend/src/features/favorites/utils/favoriteIds.ts
new file mode 100644
index 0000000..7c71616
--- /dev/null
+++ b/frontend/src/features/favorites/utils/favoriteIds.ts
@@ -0,0 +1,18 @@
+export function toggleFavoriteId(favoriteIds: string[], courseId: string): string[] {
+ return favoriteIds.includes(courseId)
+ ? favoriteIds.filter((id) => id !== courseId)
+ : [...favoriteIds, courseId]
+}
+
+export function updateSavingFavoriteIds(
+ savingFavoriteIds: string[],
+ courseId: string,
+ isSaving: boolean,
+): string[] {
+ if (isSaving) {
+ return savingFavoriteIds.includes(courseId)
+ ? savingFavoriteIds
+ : [...savingFavoriteIds, courseId]
+ }
+ return savingFavoriteIds.filter((id) => id !== courseId)
+}
diff --git a/frontend/src/features/planner/components/PlannerFavoritesPanel.tsx b/frontend/src/features/planner/components/PlannerFavoritesPanel.tsx
index 026ed5f..6c2d9d9 100644
--- a/frontend/src/features/planner/components/PlannerFavoritesPanel.tsx
+++ b/frontend/src/features/planner/components/PlannerFavoritesPanel.tsx
@@ -2,7 +2,6 @@ import { Link } from 'react-router-dom'
import type { CompletedCourse, Course } from '../../courses'
import { cleanCourseTitle, formatCourseTypeLabel } from '../../courses'
import { buildCourseAreaTags } from '../../courses/utils/courseCardDisplay.ts'
-import { encodeCatalogDetailSegment } from '../../courses/utils/catalogDetailRoute.ts'
import { ROUTES } from '../../routes'
import type { RegulationRuleGroup } from '../../../shared/utils/regulation'
import { AreaBadge } from '../../../shared/components/AreaBadge'
@@ -26,7 +25,9 @@ function CandidateCard({
activeSemesterLabel,
hiddenSlotIds,
onAddCourse,
+ onOpenCourse,
onToggleFavorite,
+ isFavoriteSaving,
onSelectTutorialSlot,
}: {
candidate: PlannerFavoriteCandidate
@@ -34,7 +35,9 @@ function CandidateCard({
activeSemesterLabel: string
hiddenSlotIds: string[]
onAddCourse: (courseId: string, areaCode: string | null) => void
+ onOpenCourse: (courseId: string) => void
onToggleFavorite: (courseId: string) => void
+ isFavoriteSaving: (courseId: string) => boolean
onSelectTutorialSlot: (courseId: string, selectedSlotId: string) => void
}) {
const { t } = useTranslation()
@@ -147,14 +150,19 @@ function CandidateCard({
event.stopPropagation()}>
- onOpenCourse(course.id)}
aria-label={t('planner.favorites.openCourseDetail')}
className="flex items-center justify-center rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-hover hover:text-primary"
>
-
- onToggleFavorite(course.id)} />
+
+ 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/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..8aefdc6 100644
--- a/frontend/src/shared/components/FavStar.tsx
+++ b/frontend/src/shared/components/FavStar.tsx
@@ -4,12 +4,13 @@ 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 showFilled = isLoading ? active : isHovered ? !active : active
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsHovered(true)}
onBlur={() => setIsHovered(false)}
- className="flex shrink-0 items-center justify-center p-1 text-primary transition-colors disabled:cursor-not-allowed disabled:opacity-50"
+ className={`flex shrink-0 items-center justify-center p-1 text-primary transition-colors disabled:cursor-default disabled:opacity-60 ${
+ isLoading ? 'animate-pulse' : ''
+ }`}
>
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), [])
+})
From f978989fb3e22f61ae04145d79979b6fbc2f7125 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Emre=20S=C3=B6zbilir?=
Date: Tue, 7 Jul 2026 21:44:36 +0200
Subject: [PATCH 2/3] Polish course save feedback and manual slot dialog
---
.../planner/components/ManualSlotDialog.tsx | 18 ++---
frontend/src/index.css | 80 +++++++++++++++++++
frontend/src/shared/components/FavStar.tsx | 17 +++-
3 files changed, 104 insertions(+), 11 deletions(-)
diff --git a/frontend/src/features/planner/components/ManualSlotDialog.tsx b/frontend/src/features/planner/components/ManualSlotDialog.tsx
index 555100e..1b7d631 100644
--- a/frontend/src/features/planner/components/ManualSlotDialog.tsx
+++ b/frontend/src/features/planner/components/ManualSlotDialog.tsx
@@ -49,7 +49,7 @@ export function ManualSlotDialog({ courses, onClose, onAdd }: ManualSlotDialogPr
role="dialog"
aria-modal="true"
aria-labelledby="manual-slot-title"
- className="w-full max-w-md rounded-[12px] border border-border bg-surface p-5 shadow-xl"
+ className="w-full max-w-md min-w-0 overflow-hidden rounded-[12px] border border-border bg-surface p-5 shadow-xl"
>
Add manual time slot
@@ -58,13 +58,13 @@ export function ManualSlotDialog({ courses, onClose, onAdd }: ManualSlotDialogPr
Place a course without catalog times — or a custom appointment — on the weekly grid.
-
-
+
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 9314a78..191b3d6 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -71,6 +71,86 @@ body {
overflow: hidden;
}
+@keyframes favorite-save-pop {
+ 0% {
+ transform: scale(1);
+ }
+ 42% {
+ transform: scale(1.34) rotate(-8deg);
+ }
+ 72% {
+ transform: scale(0.94) rotate(4deg);
+ }
+ 100% {
+ transform: scale(1) rotate(0);
+ }
+}
+
+@keyframes favorite-save-burst {
+ 0% {
+ opacity: 0.9;
+ transform: translate(-50%, -50%) scale(0.35);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(1.8);
+ }
+}
+
+@keyframes favorite-save-spark {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) rotate(var(--spark-angle)) translateY(-0.15rem) scale(0.45);
+ }
+ 35% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, -50%) rotate(var(--spark-angle)) translateY(-1rem) scale(0.85);
+ }
+}
+
+.favorite-save-pop {
+ animation: favorite-save-pop 420ms cubic-bezier(0.2, 1.35, 0.38, 1);
+}
+
+.favorite-save-burst {
+ animation: favorite-save-burst 520ms ease-out forwards;
+ border: 1px solid color-mix(in srgb, var(--color-primary) 52%, transparent);
+ border-radius: 9999px;
+ box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--color-primary) 12%, transparent);
+ height: 1.65rem;
+ left: 50%;
+ pointer-events: none;
+ position: absolute;
+ top: 50%;
+ width: 1.65rem;
+}
+
+.favorite-save-burst span {
+ animation: favorite-save-spark 560ms ease-out forwards;
+ background: var(--color-primary);
+ border-radius: 9999px;
+ height: 0.22rem;
+ left: 50%;
+ position: absolute;
+ top: 50%;
+ width: 0.22rem;
+}
+
+.favorite-save-burst span:nth-child(1) {
+ --spark-angle: -35deg;
+}
+
+.favorite-save-burst span:nth-child(2) {
+ --spark-angle: 0deg;
+}
+
+.favorite-save-burst span:nth-child(3) {
+ --spark-angle: 35deg;
+}
+
.studyplanner-tour-active [data-app-topbar] {
pointer-events: none;
}
diff --git a/frontend/src/shared/components/FavStar.tsx b/frontend/src/shared/components/FavStar.tsx
index 8aefdc6..798be38 100644
--- a/frontend/src/shared/components/FavStar.tsx
+++ b/frontend/src/shared/components/FavStar.tsx
@@ -10,6 +10,7 @@ interface FavStarProps {
export function FavStar({ active, disabled = false, isLoading = false, onToggle }: FavStarProps) {
const [isHovered, setIsHovered] = useState(false)
+ const [feedbackKey, setFeedbackKey] = useState(0)
const showFilled = isLoading ? active : isHovered ? !active : active
return (
@@ -18,6 +19,9 @@ export function FavStar({ active, disabled = false, isLoading = false, onToggle
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
+ if (!active) {
+ setFeedbackKey((current) => current + 1)
+ }
onToggle()
}}
disabled={disabled || isLoading}
@@ -28,11 +32,20 @@ export function FavStar({ active, disabled = false, isLoading = false, onToggle
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsHovered(true)}
onBlur={() => setIsHovered(false)}
- className={`flex shrink-0 items-center justify-center p-1 text-primary transition-colors disabled:cursor-default disabled:opacity-60 ${
+ className={`relative flex shrink-0 items-center justify-center overflow-visible rounded-full p-1 text-primary transition-colors disabled:cursor-default disabled:opacity-60 ${
isLoading ? 'animate-pulse' : ''
}`}
>
-
+ {feedbackKey > 0 ? (
+
+
+
+
+
+ ) : null}
+ 0 ? 'favorite-save-pop' : undefined}>
+
+
)
}
From ac8227fb0b1417290f574d965e7998d4852a783f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Emre=20S=C3=B6zbilir?=
Date: Tue, 7 Jul 2026 21:48:25 +0200
Subject: [PATCH 3/3] Soften favorite button feedback
---
frontend/src/index.css | 78 +++++++++++-----------
frontend/src/shared/components/FavStar.tsx | 26 +++++---
2 files changed, 55 insertions(+), 49 deletions(-)
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 191b3d6..c490d40 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -75,80 +75,82 @@ body {
0% {
transform: scale(1);
}
- 42% {
- transform: scale(1.34) rotate(-8deg);
+ 45% {
+ transform: scale(1.16) translateY(-1px);
}
- 72% {
- transform: scale(0.94) rotate(4deg);
+ 78% {
+ transform: scale(0.98);
}
100% {
- transform: scale(1) rotate(0);
+ transform: scale(1);
}
}
@keyframes favorite-save-burst {
0% {
- opacity: 0.9;
- transform: translate(-50%, -50%) scale(0.35);
+ opacity: 0.55;
+ transform: translate(-50%, -50%) scale(0.55);
}
100% {
opacity: 0;
- transform: translate(-50%, -50%) scale(1.8);
+ transform: translate(-50%, -50%) scale(1.35);
}
}
-@keyframes favorite-save-spark {
+@keyframes favorite-remove-pop {
0% {
- opacity: 0;
- transform: translate(-50%, -50%) rotate(var(--spark-angle)) translateY(-0.15rem) scale(0.45);
+ transform: scale(1);
+ }
+ 45% {
+ transform: scale(0.86);
+ }
+ 100% {
+ transform: scale(1);
}
- 35% {
- opacity: 1;
+}
+
+@keyframes favorite-remove-ring {
+ 0% {
+ opacity: 0.35;
+ transform: translate(-50%, -50%) scale(0.98);
}
100% {
opacity: 0;
- transform: translate(-50%, -50%) rotate(var(--spark-angle)) translateY(-1rem) scale(0.85);
+ transform: translate(-50%, -50%) scale(0.72);
}
}
-.favorite-save-pop {
- animation: favorite-save-pop 420ms cubic-bezier(0.2, 1.35, 0.38, 1);
+.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 520ms ease-out forwards;
- border: 1px solid color-mix(in srgb, var(--color-primary) 52%, transparent);
+ 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.2rem color-mix(in srgb, var(--color-primary) 12%, transparent);
- height: 1.65rem;
+ 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.65rem;
+ width: 1.45rem;
}
-.favorite-save-burst span {
- animation: favorite-save-spark 560ms ease-out forwards;
- background: var(--color-primary);
+.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: 0.22rem;
+ height: 1.25rem;
left: 50%;
+ pointer-events: none;
position: absolute;
top: 50%;
- width: 0.22rem;
-}
-
-.favorite-save-burst span:nth-child(1) {
- --spark-angle: -35deg;
-}
-
-.favorite-save-burst span:nth-child(2) {
- --spark-angle: 0deg;
-}
-
-.favorite-save-burst span:nth-child(3) {
- --spark-angle: 35deg;
+ width: 1.25rem;
}
.studyplanner-tour-active [data-app-topbar] {
diff --git a/frontend/src/shared/components/FavStar.tsx b/frontend/src/shared/components/FavStar.tsx
index 798be38..58d0396 100644
--- a/frontend/src/shared/components/FavStar.tsx
+++ b/frontend/src/shared/components/FavStar.tsx
@@ -10,7 +10,7 @@ interface FavStarProps {
export function FavStar({ active, disabled = false, isLoading = false, onToggle }: FavStarProps) {
const [isHovered, setIsHovered] = useState(false)
- const [feedbackKey, setFeedbackKey] = useState(0)
+ const [feedback, setFeedback] = useState<{ key: number; type: 'add' | 'remove' } | null>(null)
const showFilled = isLoading ? active : isHovered ? !active : active
return (
@@ -19,9 +19,10 @@ export function FavStar({ active, disabled = false, isLoading = false, onToggle
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
- if (!active) {
- setFeedbackKey((current) => current + 1)
- }
+ setFeedback((current) => ({
+ key: (current?.key ?? 0) + 1,
+ type: active ? 'remove' : 'add',
+ }))
onToggle()
}}
disabled={disabled || isLoading}
@@ -36,14 +37,17 @@ export function FavStar({ active, disabled = false, isLoading = false, onToggle
isLoading ? 'animate-pulse' : ''
}`}
>
- {feedbackKey > 0 ? (
-
-
-
-
-
+ {feedback ? (
+
) : null}
- 0 ? 'favorite-save-pop' : undefined}>
+