highlighting UI

This commit is contained in:
DaKheera47 2025-12-15 18:56:12 +00:00
parent f45ed64e75
commit 4b4ce5567f
5 changed files with 1518 additions and 14 deletions

File diff suppressed because it is too large Load Diff

View File

@ -18,9 +18,9 @@
"pipeline:run": "tsx src/server/pipeline/run.ts" "pipeline:run": "tsx src/server/pipeline/run.ts"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
@ -34,6 +34,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View File

@ -32,6 +32,8 @@ interface JobCardProps {
onReject: (id: string) => void | Promise<void>; onReject: (id: string) => void | Promise<void>;
onProcess: (id: string) => void | Promise<void>; onProcess: (id: string) => void | Promise<void>;
isProcessing: boolean; isProcessing: boolean;
highlightedJobId?: string | null;
onHighlightChange?: (jobId: string | null) => void;
} }
const formatDate = (dateStr: string | null) => { const formatDate = (dateStr: string | null) => {
@ -55,6 +57,8 @@ export const JobCard: React.FC<JobCardProps> = ({
onReject, onReject,
onProcess, onProcess,
isProcessing, isProcessing,
highlightedJobId,
onHighlightChange,
}) => { }) => {
const sourceLabel: Record<Job["source"], string> = { const sourceLabel: Record<Job["source"], string> = {
gradcracker: "Gradcracker", gradcracker: "Gradcracker",
@ -70,6 +74,7 @@ export const JobCard: React.FC<JobCardProps> = ({
const jobLink = job.applicationLink || job.jobUrl; const jobLink = job.applicationLink || job.jobUrl;
const pdfHref = `/pdfs/resume_${job.id}.pdf`; const pdfHref = `/pdfs/resume_${job.id}.pdf`;
const deadline = formatDate(job.deadline); const deadline = formatDate(job.deadline);
const isHighlighted = highlightedJobId === job.id;
const handleCopyInfo = async () => { const handleCopyInfo = async () => {
try { try {
@ -149,6 +154,16 @@ export const JobCard: React.FC<JobCardProps> = ({
Copy info Copy info
</Button> </Button>
{onHighlightChange && (
<Button
variant="outline"
size="sm"
onClick={() => onHighlightChange(isHighlighted ? null : job.id)}
>
{isHighlighted ? "Unhighlight" : "Highlight"}
</Button>
)}
{hasPdf && ( {hasPdf && (
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<a href={pdfHref} target="_blank" rel="noopener noreferrer"> <a href={pdfHref} target="_blank" rel="noopener noreferrer">

View File

@ -3,11 +3,13 @@
*/ */
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { ArrowUpDown, LayoutGrid, Search, Table2 } from "lucide-react"; import { ArrowUpDown, LayoutGrid, Search, Table2, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -170,6 +172,8 @@ const jobMatchesQuery = (job: Job, query: string) => {
return haystack.includes(normalized); return haystack.includes(normalized);
}; };
const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
export const JobList: React.FC<JobListProps> = ({ export const JobList: React.FC<JobListProps> = ({
jobs, jobs,
onApply, onApply,
@ -182,6 +186,8 @@ export const JobList: React.FC<JobListProps> = ({
const [sort, setSort] = useState<JobSort>(DEFAULT_SORT); const [sort, setSort] = useState<JobSort>(DEFAULT_SORT);
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(() => new Set()); const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(() => new Set());
const [batchAction, setBatchAction] = useState<null | "process" | "reject" | "apply">(null); const [batchAction, setBatchAction] = useState<null | "process" | "reject" | "apply">(null);
const [highlightedJobId, setHighlightedJobId] = useState<string | null>(null);
const [isHighlightVisible, setIsHighlightVisible] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>(() => { const [viewMode, setViewMode] = useState<ViewMode>(() => {
try { try {
const raw = localStorage.getItem(JOB_LIST_VIEW_STORAGE_KEY); const raw = localStorage.getItem(JOB_LIST_VIEW_STORAGE_KEY);
@ -204,6 +210,27 @@ export const JobList: React.FC<JobListProps> = ({
setSelectedJobIds(new Set()); setSelectedJobIds(new Set());
}, [activeTab, viewMode]); }, [activeTab, viewMode]);
useEffect(() => {
if (!highlightedJobId) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
setIsHighlightVisible(false);
const raf = requestAnimationFrame(() => setIsHighlightVisible(true));
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setHighlightedJobId(null);
};
window.addEventListener("keydown", onKeyDown);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
setIsHighlightVisible(false);
};
}, [highlightedJobId]);
const counts = useMemo(() => { const counts = useMemo(() => {
const byTab: Record<FilterTab, number> = { const byTab: Record<FilterTab, number> = {
ready: 0, ready: 0,
@ -250,6 +277,17 @@ export const JobList: React.FC<JobListProps> = ({
}, [jobsForTab, searchQuery, sort]); }, [jobsForTab, searchQuery, sort]);
const activeTabJobs = visibleJobsForTab.get(activeTab) ?? []; const activeTabJobs = visibleJobsForTab.get(activeTab) ?? [];
const highlightedJob = useMemo(
() => (highlightedJobId ? jobs.find((job) => job.id === highlightedJobId) ?? null : null),
[highlightedJobId, jobs],
);
const highlightedJobDescription = useMemo(() => {
if (!highlightedJob) return "No description available.";
const jd = highlightedJob.jobDescription || "No description available.";
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
return jd;
}, [highlightedJob]);
useEffect(() => { useEffect(() => {
setSelectedJobIds((current) => { setSelectedJobIds((current) => {
@ -307,11 +345,75 @@ export const JobList: React.FC<JobListProps> = ({
}; };
return ( return (
<Tabs <>
value={activeTab} {highlightedJob && (
onValueChange={(value) => setActiveTab(value as FilterTab)} <>
className="space-y-4" <div
> className={cn(
"fixed inset-0 z-40 bg-background/30 backdrop-blur-md backdrop-saturate-150 transition-opacity duration-200 ease-out",
isHighlightVisible ? "opacity-100" : "opacity-0",
)}
onClick={() => setHighlightedJobId(null)}
/>
<div
className="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-8"
onClick={() => setHighlightedJobId(null)}
>
<div
className={cn(
"mx-auto w-full max-w-4xl space-y-4 transition-all duration-200 ease-out",
isHighlightVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2",
)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-muted-foreground">Highlighted job</div>
<div className="truncate text-base font-semibold">{highlightedJob.title}</div>
</div>
<Button
variant="outline"
size="icon"
onClick={() => setHighlightedJobId(null)}
aria-label="Close highlight"
>
<X className="h-4 w-4" />
</Button>
</div>
<JobCard
job={highlightedJob}
onApply={onApply}
onReject={onReject}
onProcess={onProcess}
isProcessing={processingJobId === highlightedJob.id}
highlightedJobId={highlightedJobId}
onHighlightChange={setHighlightedJobId}
/>
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-base">Job description</CardTitle>
<div className="text-xs text-muted-foreground">Press Esc or click outside to exit highlight.</div>
</CardHeader>
<CardContent className="max-h-[60vh] overflow-auto text-sm text-muted-foreground">
<div className="whitespace-pre-wrap leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{highlightedJobDescription}
</ReactMarkdown>
</div>
</CardContent>
</Card>
</div>
</div>
</>
)}
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as FilterTab)}
className="space-y-4"
>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<TabsList className="w-full sm:w-auto h-9"> <TabsList className="w-full sm:w-auto h-9">
@ -500,6 +602,8 @@ export const JobList: React.FC<JobListProps> = ({
onReject={onReject} onReject={onReject}
onProcess={onProcess} onProcess={onProcess}
processingJobId={processingJobId} processingJobId={processingJobId}
highlightedJobId={highlightedJobId}
onHighlightChange={setHighlightedJobId}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -514,6 +618,8 @@ export const JobList: React.FC<JobListProps> = ({
onReject={onReject} onReject={onReject}
onProcess={onProcess} onProcess={onProcess}
isProcessing={processingJobId === job.id} isProcessing={processingJobId === job.id}
highlightedJobId={highlightedJobId}
onHighlightChange={setHighlightedJobId}
/> />
))} ))}
</div> </div>
@ -523,6 +629,7 @@ export const JobList: React.FC<JobListProps> = ({
</TabsContent> </TabsContent>
); );
})} })}
</Tabs> </Tabs>
</>
); );
}; };

View File

@ -59,6 +59,8 @@ export interface JobTableProps {
onReject: (id: string) => void | Promise<void>; onReject: (id: string) => void | Promise<void>;
onProcess: (id: string) => void | Promise<void>; onProcess: (id: string) => void | Promise<void>;
processingJobId: string | null; processingJobId: string | null;
highlightedJobId?: string | null;
onHighlightChange?: (jobId: string | null) => void;
} }
const sourceLabel: Record<Job["source"], string> = { const sourceLabel: Record<Job["source"], string> = {
@ -135,6 +137,8 @@ export const JobTable: React.FC<JobTableProps> = ({
onReject, onReject,
onProcess, onProcess,
processingJobId, processingJobId,
highlightedJobId,
onHighlightChange,
}) => { }) => {
const selectedCount = jobs.reduce((count, job) => count + (selectedJobIds.has(job.id) ? 1 : 0), 0); const selectedCount = jobs.reduce((count, job) => count + (selectedJobIds.has(job.id) ? 1 : 0), 0);
const allSelected = jobs.length > 0 && selectedCount === jobs.length; const allSelected = jobs.length > 0 && selectedCount === jobs.length;
@ -215,6 +219,7 @@ export const JobTable: React.FC<JobTableProps> = ({
const canReject = ["discovered", "ready"].includes(job.status); const canReject = ["discovered", "ready"].includes(job.status);
const isProcessing = processingJobId === job.id; const isProcessing = processingJobId === job.id;
const isSelected = selectedJobIds.has(job.id); const isSelected = selectedJobIds.has(job.id);
const isHighlighted = highlightedJobId === job.id;
return ( return (
<TableRow key={job.id} data-state={isSelected ? "selected" : undefined}> <TableRow key={job.id} data-state={isSelected ? "selected" : undefined}>
@ -290,6 +295,14 @@ export const JobTable: React.FC<JobTableProps> = ({
Copy info Copy info
</DropdownMenuItem> </DropdownMenuItem>
{onHighlightChange && (
<DropdownMenuItem
onSelect={() => onHighlightChange(isHighlighted ? null : job.id)}
>
{isHighlighted ? "Unhighlight" : "Highlight"}
</DropdownMenuItem>
)}
{hasPdf && ( {hasPdf && (
<> <>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>