feat: refactor admin instructor UI with component extraction and optimistic updates

This commit is contained in:
2026-01-30 19:31:31 -06:00
parent 474d519b9d
commit a103f0643a
6 changed files with 984 additions and 398 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
<script lang="ts">
import type { CandidateResponse } from "$lib/api";
import { isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
import { Check, ExternalLink, LoaderCircle, X, XCircle } from "@lucide/svelte";
import ScoreBreakdown from "./ScoreBreakdown.svelte";
let {
candidate,
isMatched = false,
isRejected = false,
disabled = false,
actionLoading = null,
isDark = false,
onmatch,
onreject,
onunmatch,
}: {
candidate: CandidateResponse;
isMatched?: boolean;
isRejected?: boolean;
disabled?: boolean;
actionLoading?: string | null;
isDark?: boolean;
onmatch?: () => void;
onreject?: () => void;
onunmatch?: () => void;
} = $props();
const isPending = $derived(!isMatched && !isRejected);
const isMatchLoading = $derived(actionLoading === `match-${candidate.rmpLegacyId}`);
const isRejectLoading = $derived(actionLoading === `reject-${candidate.rmpLegacyId}`);
const isUnmatchLoading = $derived(actionLoading === `unmatch-${candidate.rmpLegacyId}`);
</script>
<div
class="rounded-md border p-3 transition-all duration-200
{isMatched
? 'border-l-4 border-l-green-500 bg-green-500/5 border-border'
: isRejected
? 'border-border bg-card opacity-50'
: 'border-border bg-card hover:shadow-sm'}"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-foreground text-sm">
{candidate.firstName} {candidate.lastName}
</span>
{#if isMatched}
<span
class="text-[10px] rounded px-1.5 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 font-medium"
>
Matched
</span>
{:else if isRejected}
<span
class="text-[10px] rounded px-1.5 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 font-medium"
>
Rejected
</span>
{/if}
</div>
{#if candidate.department}
<div class="text-xs text-muted-foreground mt-0.5">{candidate.department}</div>
{/if}
</div>
<div class="flex items-center gap-0.5 shrink-0">
{#if isMatched}
<button
onclick={(e) => {
e.stopPropagation();
onunmatch?.();
}}
{disabled}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-xs text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Remove match"
>
{#if isUnmatchLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<XCircle size={14} />
{/if}
Unmatch
</button>
{:else if isPending}
<button
onclick={(e) => {
e.stopPropagation();
onmatch?.();
}}
{disabled}
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Accept match"
>
{#if isMatchLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<Check size={14} />
{/if}
</button>
<button
onclick={(e) => {
e.stopPropagation();
onreject?.();
}}
{disabled}
class="rounded p-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Reject candidate"
>
{#if isRejectLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<X size={14} />
{/if}
</button>
{/if}
<a
href={rmpUrl(candidate.rmpLegacyId)}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
title="View on RateMyProfessors"
>
<ExternalLink size={14} />
</a>
</div>
</div>
<!-- Rating stats -->
<div class="mt-2 flex items-center gap-3 text-xs flex-wrap">
{#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)}
<span
class="font-semibold tabular-nums"
style={ratingStyle(candidate.avgRating!, isDark)}
>
{candidate.avgRating!.toFixed(1)}
</span>
{:else}
<span class="text-muted-foreground">No rating</span>
{/if}
{#if candidate.avgDifficulty !== null}
<span class="text-muted-foreground tabular-nums"
>{candidate.avgDifficulty.toFixed(1)} diff</span
>
{/if}
<span class="text-muted-foreground tabular-nums">{candidate.numRatings} ratings</span>
{#if candidate.wouldTakeAgainPct !== null}
<span class="text-muted-foreground tabular-nums"
>{candidate.wouldTakeAgainPct.toFixed(0)}% again</span
>
{/if}
</div>
<!-- Score breakdown -->
<div class="mt-2">
<ScoreBreakdown breakdown={candidate.scoreBreakdown} score={candidate.score ?? 0} />
</div>
</div>
@@ -0,0 +1,73 @@
<script lang="ts">
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
let {
breakdown = null,
score = 0,
}: {
breakdown?: { [key in string]?: number } | null;
score?: number;
} = $props();
const weights: Record<string, number> = {
name: 0.5,
department: 0.25,
uniqueness: 0.15,
volume: 0.1,
};
const colors: Record<string, string> = {
name: "bg-blue-500",
department: "bg-purple-500",
uniqueness: "bg-amber-500",
volume: "bg-emerald-500",
};
const labels: Record<string, string> = {
name: "Name",
department: "Dept",
uniqueness: "Unique",
volume: "Volume",
};
function fmt(v: number): string {
return (v * 100).toFixed(0);
}
const segments = $derived(
Object.entries(breakdown ?? {})
.filter(([_, value]) => value != null)
.map(([key, value]) => ({
key,
label: labels[key] ?? key,
color: colors[key] ?? "bg-primary",
weight: weights[key] ?? 0,
raw: value!,
pct: value! * (weights[key] ?? 0) * 100,
}))
);
const tooltipText = $derived(
segments.map((s) => `${s.label}: ${fmt(s.raw)}% \u00d7 ${fmt(s.weight)}%`).join("\n") +
`\nTotal: ${fmt(score)}%`
);
</script>
<div class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground shrink-0">Score:</span>
<div class="bg-muted h-2 flex-1 rounded-full overflow-hidden flex">
{#each segments as seg (seg.key)}
<div
class="{seg.color} h-full transition-all duration-300"
style="width: {seg.pct}%"
></div>
{/each}
</div>
<SimpleTooltip text={tooltipText} side="top">
<span
class="tabular-nums font-medium text-foreground cursor-help border-b border-dotted border-muted-foreground/40"
>
{fmt(score)}%
</span>
</SimpleTooltip>
</div>