Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions apps/web-ui/src/app/components/EvaluateForm.tsx
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>
);
}
87 changes: 87 additions & 0 deletions apps/web-ui/src/app/components/FileUpload.tsx
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;
Comment on lines +31 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

PDF path is explicitly blocked despite being a required supported format.

Line 31–35 rejects .pdf, while Line 81 advertises .pdf in accept and the PR objective requires client-side .pdf upload support. This leaves a core requirement unimplemented and creates inconsistent UX.

Suggested direction
-        if (extension === "pdf") {
-            alert(
-                "PDF text extraction is not yet supported. Please use .txt or .md files."
-            );
-            return;
-        }
+        if (extension === "pdf") {
+            // Extract text client-side and pass to onContentLoaded(...)
+            // If extraction fails, show a clear error and do not set fileName.
+        }
...
-                <p className="mt-2 text-sm text-zinc-500">
-                    Supports .txt and .md
-                </p>
+                <p className="mt-2 text-sm text-zinc-500">
+                    Supports .txt, .md, and .pdf
+                </p>

Also applies to: 67-69, 81-81

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-ui/src/app/components/FileUpload.tsx` around lines 31 - 35, The code
explicitly rejects PDFs by checking extension === "pdf" (and similar checks at
the other locations) despite the input's accept including ".pdf" and the
requirement to support client-side PDF uploads; remove the hard reject and
instead add client-side PDF text extraction in the file processing flow (the
routine that reads uploaded files—look for the handler that inspects extension,
e.g., the extension variable and the file-read/handleUpload or processFile
function). Use a PDF parsing library (pdfjs-dist/getDocument) in that handler to
extract text from PDF pages, set the extracted text into the same state/path
used for .txt/.md, and ensure the input accept attribute remains consistent
(update the earlier checks around extension and any alert calls so PDFs are
allowed and processed rather than blocked).

}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Upload trigger is mouse-only; keyboard users can’t activate it.

Line 57–61 uses a clickable <div> without keyboard interaction semantics. This blocks task completion for keyboard-only users.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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"
<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"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-ui/src/app/components/FileUpload.tsx` around lines 57 - 61, The
clickable upload area is a plain <div> (in FileUpload component) and lacks
keyboard semantics; add keyboard accessibility by giving that element a
role="button", tabIndex={0}, an appropriate aria-label (e.g., "Upload files"),
and an onKeyDown handler that calls inputRef.current?.click() when Enter or
Space is pressed (prevent default for Space). Keep the existing onClick,
onDragOver, and onDrop handlers (handleDrop) and ensure inputRef is the same
file input used to open the file picker.

>
<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)}
/>
</>
);
}
37 changes: 37 additions & 0 deletions apps/web-ui/src/app/components/ScoreCard.tsx
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>
);
}
16 changes: 16 additions & 0 deletions apps/web-ui/src/app/components/TextStats.tsx
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>
);
}
41 changes: 41 additions & 0 deletions apps/web-ui/src/app/lib/evaluation-storage.ts
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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 .map is called on non-arrays. Validate shape here and return null (optionally clear the key) if invalid.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
return JSON.parse(raw);
} catch (error) {
console.error("Failed to parse evaluation result:", error);
return null;
}
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 {
const parsed = JSON.parse(raw);
if (!isValidEvaluationResult(parsed)) {
localStorage.removeItem(EVALUATION_RESULT_KEY);
return null;
}
return parsed;
} catch (error) {
console.error("Failed to parse evaluation result:", error);
return null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-ui/src/app/lib/evaluation-storage.ts` around lines 35 - 40, The
JSON.parse(raw) return path should validate the parsed value before returning to
the UI: after parsing the raw string (the JSON.parse(raw) call), check that the
result is the expected collection (use Array.isArray(parsed)) and that each item
is the expected object shape (at minimum typeof item === "object" && item !==
null); if validation fails, return null and optionally clear the stored key
(e.g., removeItem for the storage key) to avoid future parse/shape errors.
Update the function that contains JSON.parse(raw) to perform these checks and
only return the parsed data when it passes validation.

}
3 changes: 3 additions & 0 deletions apps/web-ui/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Header } from "./components/layout/Header";
import { EvaluateForm } from "./components/EvaluateForm";

export default function Home() {
return (
Expand Down Expand Up @@ -46,6 +47,8 @@ export default function Home() {
</p>
</article>
</section>

<EvaluateForm />

<section className="rounded-3xl border border-dashed border-zinc-300 p-8 dark:border-zinc-700">
<p className="max-w-3xl text-base leading-8 ">
Expand Down
86 changes: 86 additions & 0 deletions apps/web-ui/src/app/results/page.tsx
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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>
);
}
Loading