diff --git a/apps/web/src/lib/components/general/style/ThemePanel.svelte b/apps/web/src/lib/components/general/style/ThemePanel.svelte index 6d6c672c..92fec56a 100644 --- a/apps/web/src/lib/components/general/style/ThemePanel.svelte +++ b/apps/web/src/lib/components/general/style/ThemePanel.svelte @@ -33,9 +33,24 @@ "Pixel" ]); + const LAST_THEME_KEY = "recal-last-selected-theme"; + // Track the last selected theme for "Reset to Theme" functionality + // Load from localStorage on init let lastSelectedTheme: { name: string; colors: CalColors } | null = null; + // Load last selected theme from localStorage on mount + if (typeof window !== "undefined") { + const stored = localStorage.getItem(LAST_THEME_KEY); + if (stored) { + try { + lastSelectedTheme = JSON.parse(stored); + } catch { + localStorage.removeItem(LAST_THEME_KEY); + } + } + } + /** * Apply a preset palette */ @@ -50,6 +65,14 @@ calColors.set(hslColors); lastSelectedTheme = { name, colors: hslColors }; + // Persist to localStorage + if (typeof window !== "undefined") { + localStorage.setItem( + LAST_THEME_KEY, + JSON.stringify(lastSelectedTheme) + ); + } + // Auto-toggle dark mode for dark palettes if (DARK_PALETTES.has(name)) { darkTheme.set(true); @@ -82,6 +105,11 @@ calColors.set(DEFAULT_RCARD_COLORS); darkTheme.set(false); lastSelectedTheme = null; + + // Remove from localStorage + if (typeof window !== "undefined") { + localStorage.removeItem(LAST_THEME_KEY); + } }; /** diff --git a/apps/web/src/lib/components/recal/Calendar.svelte b/apps/web/src/lib/components/recal/Calendar.svelte index 77c91bd1..88e5b350 100644 --- a/apps/web/src/lib/components/recal/Calendar.svelte +++ b/apps/web/src/lib/components/recal/Calendar.svelte @@ -1,4 +1,5 @@
@@ -87,9 +109,11 @@ placeholder="Search" class="search-input std-area rounded-md" bind:this={inputBar} - on:input={triggerSearch} /> + on:input={triggerSearch} + on:focus={() => (searchFocused = true)} + on:blur={() => (searchFocused = false)} />
- {#if $searchSettings.style["Show Instructor(s)"]} + {#if $searchSettings.style["Show Instructors"]} {#if course.instructors && course.instructors.length > 0} {#each course.instructors as instructor}
diff --git a/apps/web/src/lib/components/recal/modals/AdvancedSearch.svelte b/apps/web/src/lib/components/recal/modals/AdvancedSearch.svelte index 49eddb5c..b8f1f765 100644 --- a/apps/web/src/lib/components/recal/modals/AdvancedSearch.svelte +++ b/apps/web/src/lib/components/recal/modals/AdvancedSearch.svelte @@ -7,216 +7,303 @@ import { DEFAULT_SETTINGS, searchSettings } from "$lib/stores/recal"; export let showModal: boolean = false; - /** - * Save settings and close modal - */ - const saveSettings = () => { - modalStore.pop(); + + // Filters with expandable sub-options + const EXPANDABLE_FILTERS = [ + "No Conflicts", + "Days", + "Rating", + "Dists", + "Levels" + ] as const; + type ExpandableFilter = (typeof EXPANDABLE_FILTERS)[number]; + + // Filter groups for organized display + const filterGroups: Record = { + "Schedule & Availability": [ + "No Conflicts", + "Open Only", + "Days", + "No Scheduled Final", + "No Cancelled" + ], + "Course Attributes": ["Rating", "Dists", "Levels"], + "Grading": ["PDFable", "PDF Only"] + }; + + // Helper text for filters (shown as tooltips) + const filterHints: Record = { + "No Conflicts": "Based on your current schedule", + "Open Only": "Only courses with available seats", + "Days": "Filter by days of the week", + "No Scheduled Final": "Exclude courses with final exams", + "Rating": "Filter by course rating (0-5)", + "Dists": "Distribution requirements", + "Levels": "100-500 level courses", + "PDFable": "Can be taken PDF", + "PDF Only": "Must be taken PDF", + "Show All": "Show all courses (ignores search)", + "No Cancelled": "Hide cancelled courses" }; let minInput: number = $searchSettings.filters["Rating"].min; let maxInput: number = $searchSettings.filters["Rating"].max; - /** - * Handle min rating input - * @param e Input event - */ - const handleMin = (e: Event) => { - let target = e.target as HTMLInputElement; - minInput = parseFloat(target.value); - if (Number.isNaN(minInput)) return; - if (minInput > maxInput) minInput = maxInput; - if (minInput < 0) minInput = 0; - if (minInput > 5) minInput = 5; - $searchSettings.filters["Rating"].min = minInput; - }; + const saveSettings = () => modalStore.pop(); + + const handleRatingInput = (e: Event, isMin: boolean) => { + const target = e.target as HTMLInputElement; + let value = parseFloat(target.value); + if (Number.isNaN(value)) return; - /** - * Handle max rating input - * @param e Input event - */ - const handleMax = (e: Event) => { - let target = e.target as HTMLInputElement; - maxInput = parseFloat(target.value); - if (Number.isNaN(maxInput)) return; - if (maxInput < minInput) maxInput = minInput; - if (maxInput < 0) maxInput = 0; - if (maxInput > 5) maxInput = 5; - $searchSettings.filters["Rating"].max = maxInput; + value = Math.max(0, Math.min(5, value)); + if (isMin) { + minInput = Math.min(value, maxInput); + $searchSettings.filters["Rating"].min = minInput; + } else { + maxInput = Math.max(value, minInput); + $searchSettings.filters["Rating"].max = maxInput; + } }; - /** - * Reset search settings to default - */ const resetSearchSettings = () => { $searchSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); minInput = 0; maxInput = 5; }; + + const setAllValues = (filter: string, value: boolean) => { + Object.keys($searchSettings.filters[filter].values).forEach(k => { + $searchSettings.filters[filter].values[k] = value; + }); + }; + + const resetRating = () => { + minInput = 0; + maxInput = 5; + $searchSettings.filters["Rating"].min = 0; + $searchSettings.filters["Rating"].max = 5; + }; + + const isExpandable = (filter: string): filter is ExpandableFilter => + EXPANDABLE_FILTERS.includes(filter as ExpandableFilter); + + const hasValues = (filter: string): boolean => + "values" in $searchSettings.filters[filter]; -
-

Advanced Search Settings

-
-
-

Filters

-
- {#each Object.keys($searchSettings.filters) as filter} - - {/each} -
+
+

Advanced Search Settings

- -
- {#if $searchSettings.filters["Rating"].enabled} -
-
-

Rating

-

- Note: courses with no rating correspond to 0 -

-
-
- - to - - - { - minInput = 0; - maxInput = 5; - $searchSettings.filters["Rating"].min = - 0; - $searchSettings.filters["Rating"].max = - 5; - }} - scheme="4" /> +
+ + {#each Object.entries(filterGroups) as [groupName, filters]} +
+
+ {groupName} +
+
+ {#each filters as filter} +
+
-
- {/if} + {/each} +
- {#each Object.keys($searchSettings.filters) as filter} - {#if $searchSettings.filters[filter].enabled && $searchSettings.filters[filter].hasOwnProperty("values")} -
-
- {#if filter === "No Conflicts"} -

- No Conflicts -

-

- "Only Available Sections" displays - only courses that have at least 1 - section of every section type - (lecture, precept, etc.) that does - not conflict and is open -

- {:else} -

- {filter} -

- {/if} -
-
+ + {#each filters.filter(f => isExpandable(f) && $searchSettings.filters[f].enabled) as filter} +
+
+ + {filter === "No Conflicts" + ? "Options" + : filter}: + + + {#if filter === "Rating"} + + + handleRatingInput(e, true)} /> + to + + handleRatingInput(e, false)} /> + + (no rating = 0) + {:else if hasValues(filter)} + {#each Object.keys($searchSettings.filters[filter].values) as value} - +
+ +
{/each} -
- - {#if filter !== "No Conflicts"} -
- { - Object.keys( - $searchSettings.filters[ - filter - ].values - ).forEach(value => { - $searchSettings.filters[ - filter - ].values[value] = true; - }); - }} - scheme="2" /> - { - Object.keys( - $searchSettings.filters[ - filter - ].values - ).forEach(value => { - $searchSettings.filters[ - filter - ].values[value] = false; - }); - }} - scheme="4" /> + + {#if filter !== "No Conflicts"} + + + {/if} + {/if} + + + {#if filter === "No Conflicts"} +
+ +
+ Shows only courses with at least one + open, non-conflicting section per + type (lecture, precept, etc.) +
{/if}
- {/if} +
{/each}
-
-
-

Sort By

-
- - - + {/each} + + +
+
+
+ Sort By +
+
+ + + +
-
-
-

Style

-
- {#each Object.keys($searchSettings.style) as style} - - {/each} + +
+
+ Display & Style +
+
+ + {#each Object.keys($searchSettings.style).filter(s => s !== "Show Time Marks") as style} + + {/each} +
- + +
+ class="flex gap-2 mt-3 pt-2 border-t border-zinc-200 dark:border-zinc-700">
-
diff --git a/apps/web/src/lib/components/ui/Checkpill.svelte b/apps/web/src/lib/components/ui/Checkpill.svelte index 40aa25c6..387d6aa0 100644 --- a/apps/web/src/lib/components/ui/Checkpill.svelte +++ b/apps/web/src/lib/components/ui/Checkpill.svelte @@ -1,10 +1,15 @@ @@ -43,30 +48,14 @@ id={name} bind:checked={$searchSettings.filters[category].values[name]} /> {/if} - - - {category === "Levels" ? name + "00" : name} - - - - - - + + {category === "Levels" ? name + "00" : name} diff --git a/apps/web/src/lib/components/ui/TogTog.svelte b/apps/web/src/lib/components/ui/TogTog.svelte index 83b19abe..79b5887c 100644 --- a/apps/web/src/lib/components/ui/TogTog.svelte +++ b/apps/web/src/lib/components/ui/TogTog.svelte @@ -32,39 +32,21 @@