-
Notifications
You must be signed in to change notification settings - Fork 13
Add CV and JD input screen with file upload and client-side evaluation #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fa014fe
02a7ce9
daa21ba
1850c49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
| import { useRouter } from "next/navigation"; | ||
| import { evaluate } from "@cv-builder/core"; | ||
| import { TextStats } from "./TextStats"; | ||
| import { saveEvaluationResult } from "../lib/evaluation-storage"; | ||
| import { FileUpload } from "./FileUpload"; | ||
|
|
||
| export function EvaluateForm() { | ||
| const router = useRouter(); | ||
|
|
||
| const [cv, setCv] = useState(""); | ||
| const [jd, setJd] = useState(""); | ||
| const [loading, setLoading] = useState(false); | ||
|
|
||
| async function handleEvaluate() { | ||
| if (!cv.trim()) { | ||
| alert("Please paste your CV."); | ||
| return; | ||
| } | ||
|
|
||
|
|
||
| setLoading(true); | ||
|
|
||
| try { | ||
| const result = await evaluate({ | ||
| cv: { | ||
| content: cv, | ||
| format: "plaintext", | ||
| }, | ||
| jd: { | ||
| content: jd, | ||
| }, | ||
| }); | ||
|
|
||
| saveEvaluationResult(result); | ||
|
|
||
| router.push("/results"); | ||
| } catch (error) { | ||
| console.error(error); | ||
| alert("Evaluation failed."); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <section className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-black/5 dark:bg-zinc-900 dark:ring-white/10"> | ||
| <div className="grid gap-6 md:grid-cols-2"> | ||
| <div> | ||
| <label className="mb-2 block font-medium"> | ||
| CV | ||
| </label> | ||
|
|
||
| <textarea | ||
| value={cv} | ||
| onChange={(e) => setCv(e.target.value)} | ||
| placeholder="Paste your CV here..." | ||
| className="min-h-[350px] w-full rounded-xl border border-zinc-300 bg-transparent p-4" | ||
| /> | ||
|
|
||
| <TextStats value={cv} /> | ||
| <FileUpload | ||
| onContentLoaded={(content) => setCv(content)} | ||
| /> | ||
| </div> | ||
|
|
||
| <div> | ||
| <label className="mb-2 block font-medium"> | ||
| Job Description | ||
| </label> | ||
|
|
||
| <textarea | ||
| value={jd} | ||
| onChange={(e) => setJd(e.target.value)} | ||
| placeholder="Paste the job description here..." | ||
| className="min-h-[350px] w-full rounded-xl border border-zinc-300 bg-transparent p-4" | ||
| /> | ||
|
|
||
| <TextStats value={jd} /> | ||
| <FileUpload | ||
| onContentLoaded={(content) => setJd(content)} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="mt-8 flex justify-center"> | ||
| <button | ||
| onClick={handleEvaluate} | ||
| disabled={loading} | ||
| className="rounded-xl bg-black px-6 py-3 text-white disabled:opacity-50" | ||
| > | ||
| {loading ? "Evaluating..." : "Evaluate"} | ||
| </button> | ||
| </div> | ||
| </section> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,87 @@ | ||||||||||||||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import { DragEvent, useRef, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| interface FileUploadProps { | ||||||||||||||||||||||||||||||||||||||
| onContentLoaded: (content: string) => void; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function FileUpload({ | ||||||||||||||||||||||||||||||||||||||
| onContentLoaded, | ||||||||||||||||||||||||||||||||||||||
| }: FileUploadProps) { | ||||||||||||||||||||||||||||||||||||||
| const [fileName, setFileName] = useState(""); | ||||||||||||||||||||||||||||||||||||||
| const inputRef = useRef<HTMLInputElement>(null); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function processFile(file: File) { | ||||||||||||||||||||||||||||||||||||||
| const extension = file.name.split(".").pop()?.toLowerCase() || ""; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (extension === "txt" || extension === "md") { | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| const text = await file.text(); | ||||||||||||||||||||||||||||||||||||||
| onContentLoaded(text); | ||||||||||||||||||||||||||||||||||||||
| setFileName(file.name); | ||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||
| console.error("Failed to read file:", error); | ||||||||||||||||||||||||||||||||||||||
| alert("Failed to read file. Please try again."); | ||||||||||||||||||||||||||||||||||||||
| setFileName(""); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (extension === "pdf") { | ||||||||||||||||||||||||||||||||||||||
| alert( | ||||||||||||||||||||||||||||||||||||||
| "PDF text extraction is not yet supported. Please use .txt or .md files." | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| alert("Unsupported file type."); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function handleFiles(files: FileList | null) { | ||||||||||||||||||||||||||||||||||||||
| if (!files?.length) return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| await processFile(files[0]); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function handleDrop( | ||||||||||||||||||||||||||||||||||||||
| event: DragEvent<HTMLDivElement> | ||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||
| event.preventDefault(); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| await handleFiles(event.dataTransfer.files); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||||||
| onDragOver={(e) => e.preventDefault()} | ||||||||||||||||||||||||||||||||||||||
| onDrop={handleDrop} | ||||||||||||||||||||||||||||||||||||||
| onClick={() => inputRef.current?.click()} | ||||||||||||||||||||||||||||||||||||||
| className="mt-4 cursor-pointer rounded-xl border-2 border-dashed border-zinc-300 p-6 text-center transition hover:border-zinc-500" | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+57
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Upload trigger is mouse-only; keyboard users can’t activate it. Line 57–61 uses a clickable Suggested fix- <div
+ <div
+ role="button"
+ tabIndex={0}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ inputRef.current?.click();
+ }
+ }}
className="mt-4 cursor-pointer rounded-xl border-2 border-dashed border-zinc-300 p-6 text-center transition hover:border-zinc-500"
>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||
| <p className="font-medium"> | ||||||||||||||||||||||||||||||||||||||
| Drag & Drop a file here | ||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| <p className="mt-2 text-sm text-zinc-500"> | ||||||||||||||||||||||||||||||||||||||
| Supports .txt and .md | ||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| {fileName && ( | ||||||||||||||||||||||||||||||||||||||
| <p className="mt-2 text-xs text-green-600"> | ||||||||||||||||||||||||||||||||||||||
| Loaded: {fileName} | ||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| <input | ||||||||||||||||||||||||||||||||||||||
| ref={inputRef} | ||||||||||||||||||||||||||||||||||||||
| type="file" | ||||||||||||||||||||||||||||||||||||||
| accept=".txt,.md,.pdf" | ||||||||||||||||||||||||||||||||||||||
| className="hidden" | ||||||||||||||||||||||||||||||||||||||
| onChange={(e) => handleFiles(e.target.files)} | ||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| interface ScoreCardProps { | ||
| score: number; | ||
| } | ||
|
|
||
| export function ScoreCard({ | ||
| score, | ||
| }: ScoreCardProps) { | ||
| if (typeof score !== 'number' || !isFinite(score) || score < 0) { | ||
| console.warn('ScoreCard received invalid score:', score); | ||
| score = 0; | ||
| } | ||
| const percentage = Math.min( | ||
| 100, | ||
| (score / 5) * 100 | ||
| ); | ||
|
|
||
| return ( | ||
| <div className="rounded-2xl border p-6"> | ||
| <h2 className="text-xl font-semibold"> | ||
| Overall Score | ||
| </h2> | ||
|
|
||
| <p className="mt-4 text-5xl font-bold"> | ||
| {score} | ||
| </p> | ||
|
|
||
| <div className="mt-4 h-3 overflow-hidden rounded-full bg-zinc-200"> | ||
| <div | ||
| className="h-full bg-zinc-900" | ||
| style={{ | ||
| width: `${percentage}%`, | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| interface TextStatsProps { | ||
| value: string; | ||
| } | ||
|
|
||
| export function TextStats({ value }: TextStatsProps) { | ||
| const trimmed = value.trim(); | ||
| const words = trimmed ? trimmed.split(/\s+/).length : 0; | ||
|
|
||
| const characters = value.length; | ||
|
|
||
| return ( | ||
| <p className="mt-2 text-sm text-zinc-500"> | ||
| {characters} chars • {words} words | ||
| </p> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,41 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const EVALUATION_RESULT_KEY = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "evaluation-result"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function saveEvaluationResult( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: unknown | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can use a proper type here. and validate it as coderabbit suggested Maybe we can use the same type defined by the backend (from the evaluate function of "@cv-builder/core") |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof window === "undefined") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| localStorage.setItem( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| EVALUATION_RESULT_KEY, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| JSON.stringify(data) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error("Failed to save evaluation result:", error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Optionally notify the user | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function getEvaluationResult() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof window === "undefined") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const raw = localStorage.getItem( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| EVALUATION_RESULT_KEY | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!raw) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return JSON.parse(raw); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error("Failed to parse evaluation result:", error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+35
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parsed storage data is not validated before returning to UI. Line 35–40 returns untyped parsed JSON directly. A stale/corrupted payload can bypass this and later crash results rendering when Suggested fix+function isValidEvaluationResult(data: unknown): data is {
+ score: number;
+ strengths: string[];
+ dimensions: Array<{ name: string; score: number; maxScore: number }>;
+} {
+ if (!data || typeof data !== "object") return false;
+ const d = data as any;
+ return (
+ typeof d.score === "number" &&
+ Array.isArray(d.strengths) &&
+ Array.isArray(d.dimensions)
+ );
+}
...
try {
- return JSON.parse(raw);
+ const parsed = JSON.parse(raw);
+ if (!isValidEvaluationResult(parsed)) {
+ localStorage.removeItem(EVALUATION_RESULT_KEY);
+ return null;
+ }
+ return parsed;
} catch (error) {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState } from "react"; | ||
| import { getEvaluationResult } from "../lib/evaluation-storage"; | ||
| import { ScoreCard } from "../components/ScoreCard"; | ||
|
|
||
| export default function ResultsPage() { | ||
| const [result, setResult] = useState<any>(null); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should not use any/unknown. This one should be fixed by adding the type to the localStorage data. It could be nice to add it to the biome rules |
||
| const [loading, setLoading] = useState(true); | ||
|
|
||
| useEffect(() => { | ||
| const data = getEvaluationResult(); | ||
|
|
||
| setResult(data); | ||
| setLoading(false); | ||
| }, []); | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <main className="p-8"> | ||
| Loading... | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| if (!result) { | ||
| return ( | ||
| <main className="p-8"> | ||
| No evaluation found | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <main className="mx-auto max-w-5xl p-8"> | ||
| <h1 className="mb-8 text-4xl font-bold"> | ||
| Evaluation Results | ||
| </h1> | ||
|
|
||
| <ScoreCard score={result.score} /> | ||
|
|
||
| <section className="mt-8 rounded-2xl border p-6"> | ||
| <h2 className="mb-4 text-xl font-semibold"> | ||
| Strengths | ||
| </h2> | ||
|
|
||
| <ul className="space-y-2"> | ||
| {result.strengths.map( | ||
| (strength: string, index: number) => ( | ||
| <li key={index}> | ||
| • {strength} | ||
| </li> | ||
| ) | ||
| )} | ||
| </ul> | ||
| </section> | ||
|
|
||
| <section className="mt-8 rounded-2xl border p-6"> | ||
| <h2 className="mb-4 text-xl font-semibold"> | ||
| Dimensions | ||
| </h2> | ||
|
|
||
| {result.dimensions.map( | ||
| (dimension: any, index: number) => ( | ||
| <div | ||
| key={index} | ||
| className="mb-3 rounded-xl border p-4" | ||
| > | ||
| <div className="flex justify-between"> | ||
| <span> | ||
| {dimension.name} | ||
| </span> | ||
|
|
||
| <span> | ||
| {dimension.score} | ||
| / | ||
| {dimension.maxScore} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| ) | ||
| )} | ||
| </section> | ||
| </main> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PDF path is explicitly blocked despite being a required supported format.
Line 31–35 rejects
.pdf, while Line 81 advertises.pdfinacceptand the PR objective requires client-side.pdfupload support. This leaves a core requirement unimplemented and creates inconsistent UX.Suggested direction
Also applies to: 67-69, 81-81
🤖 Prompt for AI Agents