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 (!$ready)
toastStore.add("error", "Please wait for the data to load");
@@ -100,8 +124,38 @@
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
- stroke="currentColor"
- class="w-6 h-6">
+ stroke={searchFocused ? "url(#theme-gradient)" : "currentColor"}
+ class="w-6 h-6 gear-icon">
+
+
+
+
+
+
+
+
+
+
+
+
+
= 4.5) {
- styles.color = "hsl(120, 52%, 75%)";
- fillStyles();
- } else if (course.rating >= 4.0) {
- styles.color = "hsl(197, 34%, 72%)";
- fillStyles();
- } else if (course.rating >= 3.5) {
- styles.color = "hsl(60, 96%, 74%)";
- fillStyles();
- } else if (course.rating >= 3.0) {
- styles.color = "hsl(35, 99%, 65%)";
- fillStyles();
- } else {
- styles.color = "hsl(1, 100%, 69%)";
- fillStyles();
- }
+ if ($darkTheme) {
+ styles.color = "hsl(0, 0%, 10%)";
+ styles.text = "hsl(0, 0%, 90%)";
+ styles.hoverColor = "hsl(0, 0%, 10%)";
+ styles.hoverText = "hsl(0, 0%, 100%)";
} else {
- if ($darkTheme) {
- styles.color = "hsl(0, 0%, 10%)";
- styles.text = "hsl(0, 0%, 90%)";
- styles.hoverColor = "hsl(0, 0%, 10%)";
- styles.hoverText = "hsl(0, 0%, 100%)";
- } else {
- styles.color = "hsl(0, 0%,100%)";
- fillStyles();
- }
+ styles.color = "hsl(0, 0%,100%)";
+ fillStyles();
}
// Dynamic color (saved courses)
@@ -193,9 +171,31 @@
{#if $searchSettings.style["Show Weighted Rating"]}
[{course.adj_rating} adj]
{/if}
+ {#if $searchSettings.style["Show Enrollment"]}
+ {@const sections =
+ $sectionData[$currentTerm]?.[course.id] || []}
+ {@const priority = ["L", "S", "C", "P", "B", "D", "U"]}
+ {@const mainCat = priority.find(cat =>
+ sections.some(s => s.category === cat)
+ )}
+ {@const mainSections = mainCat
+ ? sections.filter(s => s.category === mainCat)
+ : []}
+ {@const totalEnroll = mainSections.reduce(
+ (sum, s) => sum + s.tot,
+ 0
+ )}
+ {@const totalCap = mainSections.reduce(
+ (sum, s) => sum + (s.cap !== 999 ? s.cap : 0),
+ 0
+ )}
+ Enrollment: {totalCap > 0
+ ? `${totalEnroll}/${totalCap}`
+ : "N/A"}
+ {/if}
- {#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]}
+
+
+
+ {#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)} />
+
Reset
+
(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"}
+
+ setAllValues(filter, true)}
+ >All
+
+ setAllValues(filter, false)}
+ >None
+ {/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}
+
+
+
-
-
Style
-
- {#each Object.keys($searchSettings.style) as style}
-
- {/each}
+
+
+
+
+
+ {#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 @@
- {name}
- {sortParam.enabled ? " — " + sortParam.options[sortParam.value] : ""}
- {#if sortParam.enabled}
-
-
-
-
-
- {/if}
+ {name}{sortParam.enabled ? " — " + sortParam.options[sortParam.value] : ""}