diff --git a/.gitignore b/.gitignore index edade79..6eb6785 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ skills-lock.json .docs .scripts + +.logs \ No newline at end of file diff --git a/next.config.js b/next.config.js index e541ade..8442082 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,11 @@ import "./src/env.js"; /** @type {import("next").NextConfig} */ const config = { output: "standalone", + experimental: { + serverActions: { + bodySizeLimit: "25mb", + }, + }, images: { remotePatterns: [ { diff --git a/src/app/dashboard/resumes/[resumeId]/_components/EditorSidebar.tsx b/src/app/dashboard/resumes/[resumeId]/_components/EditorSidebar.tsx index b4f71c3..9b67626 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/EditorSidebar.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/EditorSidebar.tsx @@ -20,6 +20,8 @@ interface EditorSidebarProps { content: ResumeContent; onSave: (patch: Partial) => void; isSaving: boolean; + onTabChange?: (tabId: EditorTabId) => void; + onTemplatesOpenChange?: (open: boolean) => void; } export function EditorSidebar({ @@ -28,6 +30,8 @@ export function EditorSidebar({ content, onSave, isSaving, + onTabChange, + onTemplatesOpenChange, }: EditorSidebarProps) { const meta = EDITOR_TAB_META[activeTab]; @@ -52,7 +56,9 @@ export function EditorSidebar({
- {activeTab === "job-tailoring" && } + {activeTab === "job-tailoring" && ( + + )} {activeTab === "sections" && ( )} @@ -81,7 +87,12 @@ export function EditorSidebar({ )} {activeTab === "finish" && ( - + onTemplatesOpenChange?.(true)} + /> )}
diff --git a/src/app/dashboard/resumes/[resumeId]/_components/PdfPreview.tsx b/src/app/dashboard/resumes/[resumeId]/_components/PdfPreview.tsx index 6ed951e..8dcbe8a 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/PdfPreview.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/PdfPreview.tsx @@ -10,9 +10,16 @@ import { TemplatesPopover } from "./TemplatesPopover"; interface PdfPreviewProps { content: ResumeContent; onStyleChange: (style: "professional" | "technical" | "minimal") => void; + isTemplatesOpen?: boolean; + onTemplatesOpenChange?: (open: boolean) => void; } -export function PdfPreview({ content, onStyleChange }: PdfPreviewProps) { +export function PdfPreview({ + content, + onStyleChange, + isTemplatesOpen, + onTemplatesOpenChange, +}: PdfPreviewProps) { const [blobUrl, setBlobUrl] = useState(null); const [isCompiling, setIsCompiling] = useState(false); const [error, setError] = useState(null); @@ -110,7 +117,12 @@ export function PdfPreview({ content, onStyleChange }: PdfPreviewProps) {
- +
- +
diff --git a/src/app/dashboard/resumes/[resumeId]/_components/TemplatesPopover.tsx b/src/app/dashboard/resumes/[resumeId]/_components/TemplatesPopover.tsx index 5485877..a33b37d 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/TemplatesPopover.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/TemplatesPopover.tsx @@ -20,13 +20,19 @@ const TEMPLATES: Array<{ value: StyleValue; label: string }> = [ interface TemplatesPopoverProps { content: ResumeContent; onStyleChange: (style: StyleValue) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; } export function TemplatesPopover({ content, onStyleChange, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, }: TemplatesPopoverProps) { - const [open, setOpen] = useState(false); + const [localOpen, setLocalOpen] = useState(false); + const open = controlledOpen ?? localOpen; + const setOpen = controlledOnOpenChange ?? setLocalOpen; const current = content.style ?? "professional"; return ( diff --git a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx index d2877b7..9061e03 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx @@ -1,18 +1,60 @@ "use client"; -import { Download, Sparkles, FileText, Target, ArrowRight } from "lucide-react"; +import { useState, useTransition } from "react"; +import { Download, FileText, Target, ArrowRight, Layers, Sliders } from "lucide-react"; + +import { + testCoverLetterAction, + type TestCoverLetterResult, +} from "~/server/actions/optimizer/test/cover-letter"; import type { ResumeContent } from "../resume-content-types"; import { buildPdfPayload } from "../resume-content-types"; -import { useState } from "react"; +import type { EditorTabId } from "../editor-tabs"; +import { cn } from "~/lib/utils"; interface FinishTabProps { content: ResumeContent; resumeId: string; + onTabChange?: (tab: EditorTabId) => void; + onBrowseTemplates?: () => void; +} + +function appendLogPath(parts: string[], logPath?: string | null): string { + if (logPath) { + parts.push(`Log: ${logPath}`); + } + return parts.join("\n\n"); +} + +function formatCoverLetterResult(result: TestCoverLetterResult): string { + if (result.ok && result.outcome === "no_optimisation_in_db") { + return appendLogPath( + [`OK (expected): ${result.message}`], + result.logPath, + ); + } + if (result.ok) { + return appendLogPath( + [ + `OK: cover letter for optimisation ${result.optimisationId}`, + result.coverLetterPreview, + ], + result.logPath, + ); + } + return appendLogPath([`[${result.kind}] ${result.error}`], result.logPath); } -export function FinishTab({ content, resumeId: _resumeId }: FinishTabProps) { +export function FinishTab({ + content, + resumeId, + onTabChange, + onBrowseTemplates, +}: FinishTabProps) { const [isDownloading, setIsDownloading] = useState(false); const [downloadError, setDownloadError] = useState(null); + const [testStatus, setTestStatus] = useState(null); + const [isCoverLetterPending, startCoverLetter] = useTransition(); const handleDownload = async () => { setIsDownloading(true); @@ -42,23 +84,67 @@ export function FinishTab({ content, resumeId: _resumeId }: FinishTabProps) { } }; - const comingSoon = [ + // Dedicated cover letter generation and download (kept intact in code, but button is disabled in UI) + const handleCoverLetterTest = () => { + setTestStatus(null); + startCoverLetter(async () => { + const result = await testCoverLetterAction(resumeId); + setTestStatus(formatCoverLetterResult(result)); + + if (result.ok && result.outcome === "cover_letter_generated" && result.coverLetterText) { + try { + const blob = new Blob([result.coverLetterText], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "cover-letter.txt"; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error("Failed to download cover letter:", e); + } + } + }); + }; + + const actions = [ { icon: , label: "Tailor to a specific role", description: "Optimise your resume for a target job", + onClick: () => onTabChange?.("job-tailoring"), + disabled: false, }, { icon: , - label: "Write cover letter", + label: ( + + Write cover letter + + soon + + + ), description: "Generate a cover letter with this resume linked", + onClick: handleCoverLetterTest, + disabled: true, + pending: isCoverLetterPending, + }, + { + icon: , + label: "Browse our templates", + description: "Choose a different design or layout template", + onClick: onBrowseTemplates, + disabled: false, }, { - icon: , - label: "Refine with AI", - description: "Chat with an AI assistant to improve your resume", + icon: , + label: "Adjust sections", + description: "Show or hide sections and customize PDF section headings", + onClick: () => onTabChange?.("sections"), + disabled: false, }, - ]; + ] as const; return (
@@ -90,30 +176,43 @@ export function FinishTab({ content, resumeId: _resumeId }: FinishTabProps) { Continue editing

- {comingSoon.map((item) => ( + {actions.map((item, idx) => ( ))} + + {testStatus ? ( +
+          {testStatus}
+        
+ ) : null}
); } diff --git a/src/app/dashboard/resumes/[resumeId]/_components/tabs/JobTailoringTab.tsx b/src/app/dashboard/resumes/[resumeId]/_components/tabs/JobTailoringTab.tsx index a6c5ec4..edad498 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/tabs/JobTailoringTab.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/tabs/JobTailoringTab.tsx @@ -1,54 +1,95 @@ "use client"; -import { useState } from "react"; -import { Sparkles } from "lucide-react"; +import { useState, useTransition } from "react"; +import { Sparkles, Loader2 } from "lucide-react"; +import { testOptimizerAction } from "~/server/actions/optimizer/test/optimize"; +import { ARTIFICIAL_JOB_POSTING_RAW_TEXT } from "~/server/actions/optimizer/test/fixtures"; +import { buildPdfPayload } from "../resume-content-types"; -export function JobTailoringTab() { - const [jobDescription, setJobDescription] = useState(""); +interface JobTailoringTabProps { + resumeId: string; +} + +function appendLogPath(parts: string[], logPath?: string | null): string { + if (logPath) { + parts.push(`Log: ${logPath}`); + } + return parts.join("\n\n"); +} + +export function JobTailoringTab({ resumeId }: JobTailoringTabProps) { + const [jobDescription, setJobDescription] = useState(ARTIFICIAL_JOB_POSTING_RAW_TEXT); + const [testStatus, setTestStatus] = useState(null); + const [isOptimizePending, startOptimize] = useTransition(); + + const handleOptimize = () => { + setTestStatus(null); + startOptimize(async () => { + const result = await testOptimizerAction(resumeId, jobDescription); + + if (result.ok) { + setTestStatus(appendLogPath([`OK: optimisation ${result.optimisationId}`], result.logPath)); + if (result.optimizedResumePayload) { + try { + const payload = buildPdfPayload(result.optimizedResumePayload); + const res = await fetch("/api/pdf-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (res.ok) { + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "optimized-resume.pdf"; + a.click(); + URL.revokeObjectURL(url); + } + } catch (e) { + console.error("Failed to generate and download optimized PDF:", e); + } + } + } else { + setTestStatus(appendLogPath([`[${result.kind}] ${result.error}`], result.logPath)); + } + }); + }; return (
-