mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 02:23:34 -06:00
feat: refactor admin instructor UI with component extraction and optimistic updates
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user