standardize loading indicators with shared spinner asset

Replace ad-hoc Loader2 loading indicators with SpinnerIcon so loading states across content and notifications pages use the same Circular-indeterminate progress indicator.

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-15 04:30:07 -07:00
parent d33bacf628
commit 1f0046a8ee
5 changed files with 25 additions and 20 deletions

View File

@ -1,5 +1,5 @@
import { useMemo, useState, type ChangeEvent } from "react" import { useMemo, useState, type ChangeEvent } from "react"
import { ArrowLeft, ArrowRight, Check, GripVertical, Loader2, Plus, Rocket, Trash2, Upload } from "lucide-react" import { ArrowLeft, ArrowRight, Check, GripVertical, Plus, Rocket, Trash2, Upload } from "lucide-react"
import { Link, useLocation, useNavigate, useParams } from "react-router-dom" import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api" import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
@ -8,6 +8,7 @@ import { PracticeQuestionEditorFields } from "../../components/content-managemen
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import type { QuestionOption } from "../../types/course.types" import type { QuestionOption } from "../../types/course.types"
type Step = 1 | 2 | 3 | 4 type Step = 1 | 2 | 3 | 4
@ -356,7 +357,7 @@ export function AddNewLessonPage() {
/> />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50"> <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50">
{uploadingIntroVideo ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />} {uploadingIntroVideo ? <SpinnerIcon className="h-4 w-4" alt="" /> : <Upload className="h-4 w-4" />}
{uploadingIntroVideo ? "Uploading..." : "Upload video from computer"} {uploadingIntroVideo ? "Uploading..." : "Upload video from computer"}
<input <input
type="file" type="file"

View File

@ -1,6 +1,6 @@
import { useMemo, useRef, useState, type ChangeEvent } from "react" import { useMemo, useRef, useState, type ChangeEvent } from "react"
import { Link, useLocation, useParams, useNavigate } from "react-router-dom" import { Link, useLocation, useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, Edit, Rocket, Loader2, Upload } from "lucide-react" import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, Edit, Rocket, Upload } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
@ -9,6 +9,7 @@ import { PracticeQuestionEditorFields } from "../../components/content-managemen
import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api" import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api" import { uploadVideoFile } from "../../api/files.api"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import type { QuestionOption } from "../../types/course.types" import type { QuestionOption } from "../../types/course.types"
type Step = 1 | 2 | 3 | 4 | 5 type Step = 1 | 2 | 3 | 4 | 5
@ -526,7 +527,7 @@ export function AddNewPracticePage() {
className="gap-1.5" className="gap-1.5"
> >
{uploadingIntroVideo ? ( {uploadingIntroVideo ? (
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" alt="" />
) : ( ) : (
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
)} )}
@ -541,7 +542,7 @@ export function AddNewPracticePage() {
> >
{importingIntroVideoUrl ? ( {importingIntroVideoUrl ? (
<> <>
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> <SpinnerIcon className="mr-1.5 h-4 w-4" alt="" />
Importing URL Importing URL
</> </>
) : ( ) : (

View File

@ -10,7 +10,6 @@ import {
Languages, Languages,
Lightbulb, Lightbulb,
Link2, Link2,
Loader2,
Mic, Mic,
Plus, Plus,
Search, Search,
@ -276,7 +275,7 @@ function MediaPreviewCard({
) : null} ) : null}
{resolving ? ( {resolving ? (
<div className="flex items-center gap-2 rounded-md border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500"> <div className="flex items-center gap-2 rounded-md border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" alt="" />
Resolving media URL... Resolving media URL...
</div> </div>
) : mediaType === "image" ? ( ) : mediaType === "image" ? (
@ -460,8 +459,11 @@ export function HumanLanguagePage() {
const saved = sessionStorage.getItem(HUMAN_LANGUAGE_SCROLL_KEY) const saved = sessionStorage.getItem(HUMAN_LANGUAGE_SCROLL_KEY)
const targetY = saved ? Number(saved) : 0 const targetY = saved ? Number(saved) : 0
if (Number.isFinite(targetY) && targetY > 0) { if (Number.isFinite(targetY) && targetY > 0) {
window.requestAnimationFrame(() => window.scrollTo({ top: targetY, behavior: "auto" })) const restoreBehavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches
setTimeout(() => window.scrollTo({ top: targetY, behavior: "auto" }), 250) ? "auto"
: "smooth"
window.requestAnimationFrame(() => window.scrollTo({ top: targetY, behavior: restoreBehavior }))
setTimeout(() => window.scrollTo({ top: targetY, behavior: restoreBehavior }), 250)
} }
} catch (error) { } catch (error) {
console.error("Failed to load human-language hierarchy:", error) console.error("Failed to load human-language hierarchy:", error)
@ -1625,7 +1627,7 @@ export function HumanLanguagePage() {
disabled={creatingKey === `module-${course.course_id}-${level}`} disabled={creatingKey === `module-${course.course_id}-${level}`}
> >
{creatingKey === `module-${course.course_id}-${level}` ? ( {creatingKey === `module-${course.course_id}-${level}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" alt="" />
) : ( ) : (
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
)} )}
@ -1674,7 +1676,7 @@ export function HumanLanguagePage() {
disabled={creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`} disabled={creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
> >
{creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? ( {creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" alt="" />
) : ( ) : (
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
)} )}
@ -2152,7 +2154,7 @@ export function HumanLanguagePage() {
<div className="p-4"> <div className="p-4">
{!practiceFetch || practiceFetch.status === "loading" ? ( {!practiceFetch || practiceFetch.status === "loading" ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-sm text-grayScale-500"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-sm text-grayScale-500">
<Loader2 className="h-5 w-5 animate-spin text-brand-500" aria-hidden /> <SpinnerIcon className="h-5 w-5 text-brand-500" alt="" />
Loading questions Loading questions
</div> </div>
) : null} ) : null}
@ -2245,7 +2247,7 @@ export function HumanLanguagePage() {
> >
{loadingQuestionEditId === {loadingQuestionEditId ===
(q.question_id ?? q.id) ? ( (q.question_id ?? q.id) ? (
<Loader2 className="h-3 w-3 animate-spin" aria-hidden /> <SpinnerIcon className="h-3 w-3" alt="" />
) : null} ) : null}
Edit Edit
</Button> </Button>
@ -2422,7 +2424,7 @@ export function HumanLanguagePage() {
</DialogHeader> </DialogHeader>
{loadingPracticeForm ? ( {loadingPracticeForm ? (
<div className="flex items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-3 text-sm text-grayScale-600"> <div className="flex items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-3 text-sm text-grayScale-600">
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" alt="" />
Loading practice details... Loading practice details...
</div> </div>
) : ( ) : (
@ -2487,7 +2489,7 @@ export function HumanLanguagePage() {
/> />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50"> <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50">
{uploadingPracticeIntroVideo ? <Loader2 className="h-4 w-4 animate-spin" /> : <Video className="h-4 w-4" />} {uploadingPracticeIntroVideo ? <SpinnerIcon className="h-4 w-4" alt="" /> : <Video className="h-4 w-4" />}
{uploadingPracticeIntroVideo ? "Uploading..." : "Upload intro video"} {uploadingPracticeIntroVideo ? "Uploading..." : "Upload intro video"}
<input <input
type="file" type="file"

View File

@ -1,5 +1,5 @@
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react" import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Mic, Plus, Trash2, Upload } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -1926,7 +1926,7 @@ export function SpeakingPage() {
className="gap-1.5" className="gap-1.5"
> >
{uploadingIntroVideo ? ( {uploadingIntroVideo ? (
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" alt="" />
) : ( ) : (
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
)} )}

View File

@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Bell, Loader2, Mail, MailOpen, Megaphone } from "lucide-react" import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { FileUpload } from "../../components/ui/file-upload" import { FileUpload } from "../../components/ui/file-upload"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { getTeamMembers } from "../../api/team.api" import { getTeamMembers } from "../../api/team.api"
import type { TeamMember } from "../../types/team.types" import type { TeamMember } from "../../types/team.types"
@ -282,7 +283,7 @@ export function CreateNotificationPage() {
> >
{sending ? ( {sending ? (
<> <>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
Sending Sending
</> </>
) : ( ) : (
@ -347,7 +348,7 @@ export function CreateNotificationPage() {
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2"> <div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && ( {recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400"> <div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <SpinnerIcon className="mr-2 h-4 w-4" alt="" />
Loading users Loading users
</div> </div>
)} )}