From a5dfcdbd2ae2024ee89e2a462c6bc7b5d7735653 Mon Sep 17 00:00:00 2001 From: Waseem Ghanem Date: Fri, 7 Aug 2026 22:16:25 -0400 Subject: [PATCH] polish app UX and accessibility --- app/(protected)/error.tsx | 37 ++++++ app/(protected)/loading.tsx | 22 ++++ app/not-found.tsx | 25 ++-- src/components/AppLayout.tsx | 22 +++- src/components/AppSidebar.tsx | 31 ++++- src/components/GlobalSearch.tsx | 90 ++++++++++---- src/components/ProtectedRoute.tsx | 3 +- src/components/ScheduleScreenshotImport.tsx | 32 +++-- src/components/SemesterSetupCard.tsx | 55 ++++++--- src/components/SyllabusUploadDialog.tsx | 54 ++++++--- src/components/course/CourseMaterials.tsx | 85 ++++++++++++-- src/index.css | 14 +++ src/views/AuthCallback.tsx | 2 +- src/views/Courses.tsx | 114 +++++++++++++++--- src/views/Dashboard.tsx | 62 ++++++++-- src/views/Quizzes.tsx | 124 ++++++++++++++------ src/views/Schedule.tsx | 73 +++++++++--- 17 files changed, 671 insertions(+), 174 deletions(-) create mode 100644 app/(protected)/error.tsx create mode 100644 app/(protected)/loading.tsx diff --git a/app/(protected)/error.tsx b/app/(protected)/error.tsx new file mode 100644 index 0000000..1170187 --- /dev/null +++ b/app/(protected)/error.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Link } from "@/lib/router"; + +export default function ProtectedError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error("[StudyOS] protected route failed", error); + }, [error]); + + return ( + + +
+ +
+

This page could not be loaded

+

+ Your data is safe. Try loading the page again, or return to the dashboard if the problem continues. +

+
+ + +
+
+
+ ); +} diff --git a/app/(protected)/loading.tsx b/app/(protected)/loading.tsx new file mode 100644 index 0000000..55e2d31 --- /dev/null +++ b/app/(protected)/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function ProtectedLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+
+ + +
+ Loading your StudyOS workspace… +
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx index b45dfe9..2a2ae42 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,15 +1,26 @@ import Link from "next/link"; +import { ArrowLeft, SearchX } from "lucide-react"; export default function NotFound() { return ( -
-
-

404

-

Oops! Page not found

- - Return to Home +
+
+
+ +
+

404 error

+

Page not found

+

+ The page may have moved, or the link may be incorrect. +

+ + + Return to dashboard
-
+ ); } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index ae32f3a..d5c4ff7 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -31,10 +31,16 @@ export function AppLayout({ children }: { children: ReactNode }) { return (
+ + Skip to main content +
-
-
+
+
@@ -42,9 +48,13 @@ export function AppLayout({ children }: { children: ReactNode }) { -
-
+
{children}
diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index b9458ad..0f34d83 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -56,7 +56,17 @@ const gradientToColor: Record = { "gradient-sunset": "text-amber", }; -function NavSection({ label, items, collapsed }: { label: string; items: typeof mainNav; collapsed: boolean }) { +function NavSection({ + label, + items, + collapsed, + onNavigate, +}: { + label: string; + items: typeof mainNav; + collapsed: boolean; + onNavigate: () => void; +}) { return ( @@ -70,6 +80,7 @@ function NavSection({ label, items, collapsed }: { label: string; items: typeof @@ -86,9 +97,12 @@ function NavSection({ label, items, collapsed }: { label: string; items: typeof } export function AppSidebar() { - const { state } = useSidebar(); + const { state, isMobile, setOpenMobile } = useSidebar(); const collapsed = state === "collapsed"; const { signOut, user } = useAuth(); + const closeMobileNavigation = () => { + if (isMobile) setOpenMobile(false); + }; const { data: courses = [] } = useQuery({ queryKey: ["courses", user?.id, "summary"], @@ -117,9 +131,9 @@ export function AppSidebar() { - - - + + + {/* Dynamic Courses */} {courses.length > 0 && ( @@ -134,6 +148,7 @@ export function AppSidebar() { @@ -157,6 +172,7 @@ export function AppSidebar() { @@ -168,7 +184,10 @@ export function AppSidebar() { )} - ⌘K + ⌘K
- {open && (allItems.length > 0 || (query.trim() && !loading)) && ( -
+ {open && (allItems.length > 0 || (query.trim() && (loading || searchError || results.length === 0))) && ( +
- {query.trim() && !loading && results.length === 0 && ( + {query.trim() && loading && ( +
+ + Searching… +
+ )} + {query.trim() && !loading && searchError && ( +

{searchError}

+ )} + {query.trim() && !loading && !searchError && results.length === 0 && (

No results found.

)} @@ -214,7 +250,7 @@ export function GlobalSearch() {
)} - {!showRecent && Object.entries(grouped).map(([cat, items]) => { + {!loading && !searchError && !showRecent && Object.entries(grouped).map(([cat, items]) => { const config = CATEGORY_CONFIG[cat as keyof typeof CATEGORY_CONFIG]; return (
@@ -227,7 +263,10 @@ export function GlobalSearch() { return ( {/* Event Review Dialog */} - - + + Review Detected Events ({extractedEvents.filter((e) => e.selected).length}/{extractedEvents.length}) @@ -296,10 +310,12 @@ export default function ScheduleScreenshotImport({ currentDate, label = "Import
{extractedEvents.map((ev, i) => ( -
toggleEvent(i)} - className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${ + aria-pressed={Boolean(ev.selected)} + className={`flex w-full cursor-pointer items-start gap-3 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${ ev.selected ? "border-primary/30 bg-primary/5" : "border-border bg-muted/10 opacity-60" @@ -322,11 +338,11 @@ export default function ScheduleScreenshotImport({ currentDate, label = "Import {ev.event_type} -
+ ))}
-
+
diff --git a/src/components/SemesterSetupCard.tsx b/src/components/SemesterSetupCard.tsx index a2a1596..b5186bc 100644 --- a/src/components/SemesterSetupCard.tsx +++ b/src/components/SemesterSetupCard.tsx @@ -692,13 +692,15 @@ function ImportDialog({ onChooseSource: (source: AcademicCalendarSourceCandidate) => void; }) { const parsedYear = parseAcademicYear(termYear); - const canSearch = collegeName.trim().length > 0 && Boolean(parsedYear) && !searchMutation.isPending; + const universityNeedsDetail = collegeName.trim().length > 0 && isUniversityNameVague(collegeName); + const yearIsInvalid = termYear.length > 0 && !parsedYear; + const canSearch = collegeName.trim().length > 0 && !universityNeedsDetail && Boolean(parsedYear) && !searchMutation.isPending; const selectedCount = parsedDates.filter((d) => d.selected).length; return ( - - + + {step === "form" ? "Import Academic Calendar" : step === "candidates" ? "Choose Source" : "Review Dates"} @@ -719,50 +721,60 @@ function ImportDialog({ initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -10 }} - className="space-y-4 px-6 pb-6 pt-5" + className="space-y-4 px-4 pb-6 pt-5 sm:px-6" >
- + setCollegeName(e.target.value)} + aria-invalid={universityNeedsDetail} + aria-describedby="academic-university-help" /> -

Use the full official school name, not just “Columbia”.

+

+ {universityNeedsDetail ? "Enter the full official university name." : "Use the full official school name, not just “Columbia”."} +

- - setTermName(e.target.value as AcademicTermName)}> {TERM_OPTIONS.map((option) => ( ))}
- + setTermYear(e.target.value.replace(/[^\d]/g, "").slice(0, 4))} + aria-invalid={yearIsInvalid} + aria-describedby={yearIsInvalid ? "academic-year-error" : undefined} /> + {yearIsInvalid &&

Enter a four-digit academic year.

}
-