Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface CourseDetailDrawerProps {
listCourse?: Course | null
isFavorite: boolean
favoriteDisabled?: boolean
favoriteLoading?: boolean
showFavorite?: boolean
onToggleFavorite: () => void
onClose: () => void
Expand All @@ -27,6 +28,7 @@ export function CourseDetailDrawer({
listCourse = null,
isFavorite,
favoriteDisabled = false,
favoriteLoading = false,
showFavorite = true,
onToggleFavorite,
onClose,
Expand Down Expand Up @@ -78,7 +80,12 @@ export function CourseDetailDrawer({
</span>
<div className="flex items-center gap-1.5">
{showFavorite ? (
<FavStar active={isFavorite} disabled={favoriteDisabled} onToggle={onToggleFavorite} />
<FavStar
active={isFavorite}
disabled={favoriteDisabled}
isLoading={favoriteLoading}
onToggle={onToggleFavorite}
/>
) : null}
<button
type="button"
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/features/courses/components/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export function CoursesOverview() {
const { courses, isLoading, error, refreshWarning } = useCatalogCourses(search, CATALOG_LIMIT, ALL_CATALOG_PERIODS)
const { regulationVersion, isLoadingRegulationVersion, regulationVersionError } =
useRegulationVersion(user?.profile.regulationVersionCode)
const { isFavorite, isLoadingFavorites, isSavingFavorites, favoritesError, toggleFavorite } =
const { isFavorite, isLoadingFavorites, isFavoriteSaving, favoritesError, toggleFavorite } =
useFavorites()
const { completedCourses } = useTranscript()
const { progressSnapshot } = useProgressSnapshot()
Expand Down Expand Up @@ -840,7 +840,8 @@ export function CoursesOverview() {
historicalLecturerLookup,
)
}
favoriteDisabled={isTourSampleRow || isLoadingFavorites || isSavingFavorites}
favoriteDisabled={isTourSampleRow || isLoadingFavorites}
favoriteLoading={!isTourSampleRow && isFavoriteSaving(course.id)}
showFavorite={canShowFavorites}
offeringStatus={offeringStatus}
seasonTermType={isTourSampleRow ? course.termType : detailSeasonTermTypeByCourseId.get(course.id) ?? course.termType}
Expand Down Expand Up @@ -872,7 +873,8 @@ export function CoursesOverview() {
courseId={openCourseId}
listCourse={courses.find((course) => course.id === openCourseId) ?? null}
isFavorite={isFavorite(openCourseId)}
favoriteDisabled={isLoadingFavorites || isSavingFavorites}
favoriteDisabled={isLoadingFavorites}
favoriteLoading={isFavoriteSaving(openCourseId)}
showFavorite={canShowFavorites}
onToggleFavorite={() => toggleFavorite(openCourseId)}
onClose={() => navigate(catalogBasePath)}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/favorites/FavoritesContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface FavoritesContextValue {
isSavingFavorites: boolean
favoritesError: string | null
isFavorite: (courseId: string) => boolean
isFavoriteSaving: (courseId: string) => boolean
toggleFavorite: (courseId: string) => void
}

Expand Down
17 changes: 11 additions & 6 deletions frontend/src/features/favorites/components/FavoritesProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,8 +26,9 @@ export function FavoritesProvider({ children }: FavoritesProviderProps): JSX.Ele
const userCacheKey = user?.username ?? 'anonymous'
const [favoriteIds, setFavoriteIds] = useState<string[]>([])
const [isLoadingFavorites, setIsLoadingFavorites] = useState<boolean>(false)
const [isSavingFavorites, setIsSavingFavorites] = useState<boolean>(false)
const [savingFavoriteCourseIds, setSavingFavoriteCourseIds] = useState<string[]>([])
const [favoritesError, setFavoritesError] = useState<string | null>(null)
const isSavingFavorites = savingFavoriteCourseIds.length > 0

useEffect(() => {
let isActive = true
Expand Down Expand Up @@ -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

Expand All @@ -103,7 +107,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps): JSX.Ele
setFavoritesError(normalizeErrorMessage(error))
})
.finally(() => {
setIsSavingFavorites(false)
setSavingFavoriteCourseIds((current) => updateSavingFavoriteIds(current, courseId, false))
})
}

Expand All @@ -115,6 +119,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps): JSX.Ele
isSavingFavorites,
favoritesError,
isFavorite,
isFavoriteSaving,
toggleFavorite,
}}
>
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/features/favorites/utils/favoriteIds.ts
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 9 additions & 9 deletions frontend/src/features/planner/components/ManualSlotDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<h2 id="manual-slot-title" className="text-[15px] font-semibold text-fg">
Add manual time slot
Expand All @@ -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.
</p>

<div className="mt-4 grid gap-3">
<label className="grid gap-1">
<div className="mt-4 grid min-w-0 gap-3">
<label className="grid min-w-0 gap-1">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-fg-muted">Course</span>
<select
value={courseId}
onChange={(event) => setCourseId(event.target.value)}
className="rounded-md border border-border bg-surface px-2.5 py-2 text-[13px] text-fg outline-none focus:border-primary"
className="w-full min-w-0 max-w-full truncate rounded-md border border-border bg-surface px-2.5 py-2 text-[13px] text-fg outline-none focus:border-primary"
>
{sortedCourses.map((course) => (
<option key={course.id} value={course.id}>
Expand All @@ -74,7 +74,7 @@ export function ManualSlotDialog({ courses, onClose, onAdd }: ManualSlotDialogPr
</select>
</label>

<label className="grid gap-1">
<label className="grid min-w-0 gap-1">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-fg-muted">Day</span>
<div className="flex flex-wrap gap-1.5">
{DAY_ORDER.map((weekday) => (
Expand All @@ -94,24 +94,24 @@ export function ManualSlotDialog({ courses, onClose, onAdd }: ManualSlotDialogPr
</div>
</label>

<label className="grid gap-1">
<label className="grid min-w-0 gap-1">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-fg-muted">Time</span>
<input
type="text"
value={time}
onChange={(event) => setTime(event.target.value)}
placeholder="10:00 - 12:00"
className="rounded-md border border-border bg-surface px-2.5 py-2 text-[13px] text-fg outline-none focus:border-primary"
className="w-full min-w-0 max-w-full rounded-md border border-border bg-surface px-2.5 py-2 text-[13px] text-fg outline-none focus:border-primary"
/>
</label>

<label className="grid gap-1">
<label className="grid min-w-0 gap-1">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-fg-muted">Room (optional)</span>
<input
type="text"
value={room}
onChange={(event) => setRoom(event.target.value)}
className="rounded-md border border-border bg-surface px-2.5 py-2 text-[13px] text-fg outline-none focus:border-primary"
className="w-full min-w-0 max-w-full rounded-md border border-border bg-surface px-2.5 py-2 text-[13px] text-fg outline-none focus:border-primary"
/>
</label>
</div>
Expand Down
24 changes: 19 additions & 5 deletions frontend/src/features/planner/components/PlannerFavoritesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,15 +25,19 @@ function CandidateCard({
activeSemesterLabel,
hiddenSlotIds,
onAddCourse,
onOpenCourse,
onToggleFavorite,
isFavoriteSaving,
onSelectTutorialSlot,
}: {
candidate: PlannerFavoriteCandidate
studyProgramCode: string | null
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()
Expand Down Expand Up @@ -147,14 +150,19 @@ function CandidateCard({
</div>

<div className="flex shrink-0 items-start gap-1" onClick={(event) => event.stopPropagation()}>
<Link
to={`${ROUTES.catalog}/${encodeCatalogDetailSegment(course.id)}`}
<button
type="button"
onClick={() => 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"
>
<InfoIcon size={15} />
</Link>
<FavStar active onToggle={() => onToggleFavorite(course.id)} />
</button>
<FavStar
active
isLoading={isFavoriteSaving(course.id)}
onToggle={() => onToggleFavorite(course.id)}
/>
</div>
</div>
</div>
Expand All @@ -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
}
Expand All @@ -200,7 +210,9 @@ export function PlannerFavoritesPanel({
catalogTo = ROUTES.catalog,
onSetAssignment,
onAddCourse,
onOpenCourse,
onToggleFavorite,
isFavoriteSaving,
hiddenSlotIds,
onSelectTutorialSlot,
}: PlannerFavoritesPanelProps) {
Expand Down Expand Up @@ -259,7 +271,9 @@ export function PlannerFavoritesPanel({
activeSemesterLabel={activeSemesterLabel}
hiddenSlotIds={hiddenSlotIds}
onAddCourse={onAddCourse}
onOpenCourse={onOpenCourse}
onToggleFavorite={onToggleFavorite}
isFavoriteSaving={isFavoriteSaving}
onSelectTutorialSlot={onSelectTutorialSlot}
/>
</div>
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/features/planner/components/SemesterPlanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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}
/>
Expand Down
Loading
Loading