because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(client): update education, CV, and job-experience pages for profile model

+228 -213
+3
apps/client/src/features/applications/components/ApplicationFlow.tsx
··· 2 2 import { useQueryClient } from "@tanstack/react-query"; 3 3 import { useState } from "react"; 4 4 import { useNavigate } from "react-router-dom"; 5 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 5 6 import { useCreateApplicationMutation, useCvQuery } from "@/generated/graphql"; 6 7 import { CVTemplateSelector } from "./CVTemplateSelector"; 7 8 import { VacancySelector } from "./VacancySelector"; 8 9 9 10 export const ApplicationFlow = () => { 11 + const profileId = useActiveProfileId(); 10 12 const navigate = useNavigate(); 11 13 const queryClient = useQueryClient(); 12 14 const [selectedVacancyId, setSelectedVacancyId] = useState<string | null>( ··· 29 31 try { 30 32 await createApplicationMutation.mutateAsync({ 31 33 input: { 34 + profileId, 32 35 vacancyId: selectedVacancyId, 33 36 cvId: selectedCVId, 34 37 coverLetter: coverLetter || null,
+4 -2
apps/client/src/features/applications/components/CVTemplateSelector.tsx
··· 1 1 import { Button, TextInput, useProgressContext } from "@cv/ui"; 2 2 import { useState } from "react"; 3 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 3 4 import { useCvTemplatesQuery, useMyCVsQuery } from "@/generated/graphql"; 4 5 5 6 interface CVTemplateSelectorProps { ··· 13 14 onCancel, 14 15 onBack, 15 16 }: CVTemplateSelectorProps) => { 17 + const profileId = useActiveProfileId(); 16 18 const [searchTerm, setSearchTerm] = useState(""); 17 19 const [selectedCVId, setSelectedCVId] = useState<string | null>(null); 18 20 const [showTemplates, setShowTemplates] = useState(false); ··· 21 23 const progressContext = useProgressContext(); 22 24 23 25 const { data: cvsData, isLoading: cvsLoading } = useMyCVsQuery({ 24 - first: 50, 26 + profileId, 25 27 }); 26 28 27 29 const { data: templatesData, isLoading: templatesLoading } = ··· 29 31 first: 50, 30 32 }); 31 33 32 - const cvs = cvsData?.me?.cvs?.edges?.map((edge) => edge.node) || []; 34 + const cvs = cvsData?.profile?.cvs?.edges?.map((e) => e.node) ?? []; 33 35 const templates = 34 36 templatesData?.cvTemplates?.edges?.map((edge) => edge.node) || []; 35 37
+4 -2
apps/client/src/features/applications/components/MatchIndicator.tsx
··· 1 1 import { useRef, useState } from "react"; 2 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 2 3 import { useMeJobExperienceQuery } from "@/generated/graphql"; 3 4 import { calculateSkillsMatch } from "@/utils/skillsMatcher"; 4 5 import { MatchTooltip } from "./MatchTooltip"; ··· 36 37 vacancy, 37 38 compact = false, 38 39 }: MatchIndicatorProps) => { 40 + const profileId = useActiveProfileId(); 39 41 const [isTooltipOpen, setIsTooltipOpen] = useState(false); 40 42 const [isClicked, setIsClicked] = useState(false); 41 43 const containerRef = useRef<HTMLDivElement>(null); 42 44 43 - const { data: jobExperienceData } = useMeJobExperienceQuery(); 45 + const { data: jobExperienceData } = useMeJobExperienceQuery({ profileId }); 44 46 45 47 // Extract skills from user's job experience 46 48 const experiences = 47 - jobExperienceData?.me?.experience?.edges?.map((edge) => edge.node) || []; 49 + jobExperienceData?.profile?.experience?.edges?.map((edge) => edge.node) || []; 48 50 const userSkills = experiences.flatMap( 49 51 (job) => 50 52 job.skills?.map((skill: { id: string; name: string }) => ({
+1
apps/client/src/features/cv-templates/mutations/create-cv.graphql
··· 1 1 mutation CreateCV($input: CreateCVInput!) { 2 2 createCV(input: $input) { 3 3 id 4 + profileId 4 5 title 5 6 template { 6 7 id
+3
apps/client/src/features/cv-templates/mutations/generate-pdf.graphql
··· 1 + mutation GeneratePdf($cvId: String!) { 2 + generatePdf(cvId: $cvId) 3 + }
+4 -4
apps/client/src/features/cv-templates/queries/my-cvs.graphql
··· 1 - query MyCVs($first: Int, $after: String) { 2 - me { 1 + query MyCVs($profileId: ID!, $first: Int, $after: String) { 2 + profile(id: $profileId) { 3 3 cvs(first: $first, after: $after) { 4 + totalCount 4 5 edges { 5 6 node { 6 7 id 8 + profileId 7 9 title 8 10 template { 9 11 id ··· 13 15 createdAt 14 16 updatedAt 15 17 } 16 - cursor 17 18 } 18 19 pageInfo { 19 20 hasNextPage ··· 21 22 startCursor 22 23 endCursor 23 24 } 24 - totalCount 25 25 } 26 26 } 27 27 }
+5
apps/client/src/features/cv-templates/queries/render-cv.graphql
··· 1 + query RenderCV($cvId: String!) { 2 + renderCV(cvId: $cvId) { 3 + html 4 + } 5 + }
+4 -2
apps/client/src/features/education/components/EducationForm.tsx
··· 8 8 } from "@cv/ui"; 9 9 import { useQueryClient } from "@tanstack/react-query"; 10 10 import { useId, useState } from "react"; 11 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 11 12 import { SelectedSkillsDisplay } from "@/features/job-experience/components/SelectedSkillsDisplay"; 12 13 import { SkillsSelect } from "@/features/job-experience/components/SkillsSelect"; 13 14 import { ··· 34 35 educationId, 35 36 }: EducationFormProps) => { 36 37 const isEditMode = !!educationId; 38 + const profileId = useActiveProfileId(); 37 39 const { mutateAsync: createEducation, isPending: createLoading } = 38 40 useCreateEducationMutation(); 39 41 const { mutateAsync: updateEducation, isPending: updateLoading } = ··· 69 71 70 72 if (!result.success) { 71 73 const newErrors: Partial<Record<keyof EducationFormData, string>> = {}; 72 - result.error.errors.forEach((error) => { 74 + result.error.issues.forEach((error) => { 73 75 if (error.path.length > 0) { 74 76 newErrors[error.path[0] as keyof EducationFormData] = error.message; 75 77 } ··· 97 99 if (isEditMode) { 98 100 await updateEducation({ id: educationId, ...variables }); 99 101 } else { 100 - await createEducation(variables); 102 + await createEducation({ profileId, ...variables }); 101 103 } 102 104 103 105 await queryClient.invalidateQueries({
+6 -2
apps/client/src/features/education/components/EducationTable.tsx
··· 16 16 import { useQueryClient } from "@tanstack/react-query"; 17 17 import type { MeEducationQuery } from "@/generated/graphql"; 18 18 import { useDeleteEducationMutation } from "@/generated/graphql"; 19 + import { calculateDuration } from "@/utils/dateUtils"; 19 20 20 21 interface EducationTableProps { 21 22 educations: NonNullable< 22 - NonNullable<MeEducationQuery["me"]>["educationHistory"] 23 + NonNullable<MeEducationQuery["profile"]>["educationHistory"] 23 24 >["edges"][number]["node"][]; 24 25 onEdit?: ( 25 26 education: NonNullable< 26 - NonNullable<MeEducationQuery["me"]>["educationHistory"] 27 + NonNullable<MeEducationQuery["profile"]>["educationHistory"] 27 28 >["edges"][number]["node"], 28 29 ) => void; 29 30 onDelete?: () => void; ··· 123 124 endDate={endDate} 124 125 variant="muted" 125 126 /> 127 + <p className="text-xs text-ctp-subtext1 mt-0.5"> 128 + {calculateDuration(startDate, endDate)} 129 + </p> 126 130 </div> 127 131 </TableCell> 128 132 <TableCell>
+2 -2
apps/client/src/features/education/components/education.schema.ts
··· 1 - import { z } from "zod"; 1 + import { z } from "zod/v4"; 2 2 3 3 export const educationFormSchema = z 4 4 .object({ ··· 11 11 .string() 12 12 .max(200, "Field of study must be less than 200 characters") 13 13 .optional(), 14 - startDate: z.date({ required_error: "Start date is required" }).nullable(), 14 + startDate: z.date({ error: "Start date is required" }).nullable(), 15 15 endDate: z.date().optional().nullable(), 16 16 description: z.string().optional(), 17 17 isCurrentEducation: z.boolean().default(false),
+3
apps/client/src/features/education/queries/create-education.graphql
··· 1 1 mutation CreateEducation( 2 + $profileId: String! 2 3 $institutionId: String! 3 4 $degree: String! 4 5 $fieldOfStudy: String ··· 8 9 $skillIds: [String!] 9 10 ) { 10 11 createEducation( 12 + profileId: $profileId 11 13 institutionId: $institutionId 12 14 degree: $degree 13 15 fieldOfStudy: $fieldOfStudy ··· 17 19 skillIds: $skillIds 18 20 ) { 19 21 id 22 + profileId 20 23 institution { 21 24 id 22 25 name
+3 -3
apps/client/src/features/education/queries/me-education.graphql
··· 1 - query MeEducation { 2 - me { 1 + query MeEducation($profileId: ID!) { 2 + profile(id: $profileId) { 3 3 educationHistory { 4 4 edges { 5 5 node { 6 6 id 7 - userId 7 + profileId 8 8 institution { 9 9 id 10 10 name
+2 -2
apps/client/src/features/job-experience/components/JobExperienceCard.tsx
··· 11 11 12 12 interface JobExperienceCardProps { 13 13 experience: NonNullable< 14 - NonNullable<MeJobExperienceQuery["me"]>["experience"] 14 + NonNullable<MeJobExperienceQuery["profile"]>["experience"] 15 15 >["edges"][number]["node"]; 16 16 onEdit?: 17 17 | (( 18 18 experience: NonNullable< 19 - NonNullable<MeJobExperienceQuery["me"]>["experience"] 19 + NonNullable<MeJobExperienceQuery["profile"]>["experience"] 20 20 >["edges"][number]["node"], 21 21 ) => void) 22 22 | undefined;
+4 -2
apps/client/src/features/job-experience/components/JobExperienceForm.tsx
··· 1 1 import { Button, Calendar, Checkbox, Textarea, useToast } from "@cv/ui"; 2 2 import { useQueryClient } from "@tanstack/react-query"; 3 3 import { useId, useState } from "react"; 4 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 4 5 import { 5 6 useCreateJobExperienceMutation, 6 7 useUpdateJobExperienceMutation, ··· 29 30 experienceId, 30 31 }: JobExperienceFormProps) => { 31 32 const isEditMode = !!experienceId; 33 + const profileId = useActiveProfileId(); 32 34 const { mutateAsync: createJobExperience, isPending: createLoading } = 33 35 useCreateJobExperienceMutation(); 34 36 const { mutateAsync: updateJobExperience, isPending: updateLoading } = ··· 65 67 if (!result.success) { 66 68 const newErrors: Partial<Record<keyof JobExperienceFormData, string>> = 67 69 {}; 68 - result.error.errors.forEach((error) => { 70 + result.error.issues.forEach((error) => { 69 71 if (error.path.length > 0) { 70 72 newErrors[error.path[0] as keyof JobExperienceFormData] = 71 73 error.message; ··· 94 96 if (isEditMode) { 95 97 await updateJobExperience({ id: experienceId, ...variables }); 96 98 } else { 97 - await createJobExperience(variables); 99 + await createJobExperience({ profileId, ...variables }); 98 100 } 99 101 100 102 await queryClient.invalidateQueries({
+2 -2
apps/client/src/features/job-experience/components/JobExperienceList.tsx
··· 3 3 4 4 interface JobExperienceListProps { 5 5 experiences: NonNullable< 6 - NonNullable<MeJobExperienceQuery["me"]>["experience"] 6 + NonNullable<MeJobExperienceQuery["profile"]>["experience"] 7 7 >; 8 8 onEdit?: ( 9 9 experience: NonNullable< 10 - NonNullable<MeJobExperienceQuery["me"]>["experience"] 10 + NonNullable<MeJobExperienceQuery["profile"]>["experience"] 11 11 >["edges"][number]["node"], 12 12 ) => void; 13 13 onDelete?: (experienceId: string) => void;
+6 -2
apps/client/src/features/job-experience/components/JobExperienceTable.tsx
··· 16 16 import { useQueryClient } from "@tanstack/react-query"; 17 17 import type { MeJobExperienceQuery } from "@/generated/graphql"; 18 18 import { useDeleteJobExperienceMutation } from "@/generated/graphql"; 19 + import { calculateDuration } from "@/utils/dateUtils"; 19 20 20 21 interface JobExperienceTableProps { 21 22 experiences: NonNullable< 22 - NonNullable<MeJobExperienceQuery["me"]>["experience"] 23 + NonNullable<MeJobExperienceQuery["profile"]>["experience"] 23 24 >["edges"][number]["node"][]; 24 25 onEdit?: ( 25 26 experience: NonNullable< 26 - NonNullable<MeJobExperienceQuery["me"]>["experience"] 27 + NonNullable<MeJobExperienceQuery["profile"]>["experience"] 27 28 >["edges"][number]["node"], 28 29 ) => void; 29 30 onDelete?: () => void; ··· 139 140 endDate={endDate} 140 141 variant="muted" 141 142 /> 143 + <p className="text-xs text-ctp-subtext1 mt-0.5"> 144 + {calculateDuration(startDate, endDate)} 145 + </p> 142 146 </div> 143 147 </TableCell> 144 148 <TableCell>
+1 -1
apps/client/src/features/job-experience/components/jobExperience.schema.ts
··· 1 - import { z } from "zod"; 1 + import { z } from "zod/v4"; 2 2 3 3 export const jobExperienceFormSchema = z 4 4 .object({
+3
apps/client/src/features/job-experience/queries/create-job-experience.graphql
··· 1 1 mutation CreateJobExperience( 2 + $profileId: String! 2 3 $companyId: String! 3 4 $roleId: String! 4 5 $levelId: String! ··· 8 9 $skillIds: [String!] 9 10 ) { 10 11 createJobExperience( 12 + profileId: $profileId 11 13 companyId: $companyId 12 14 roleId: $roleId 13 15 levelId: $levelId ··· 17 19 skillIds: $skillIds 18 20 ) { 19 21 id 22 + profileId 20 23 company { 21 24 id 22 25 name
+3 -2
apps/client/src/features/job-experience/queries/me-job-experience.graphql
··· 1 - query MeJobExperience { 2 - me { 1 + query MeJobExperience($profileId: ID!) { 2 + profile(id: $profileId) { 3 3 experience { 4 4 edges { 5 5 node { 6 6 id 7 + profileId 7 8 startDate 8 9 endDate 9 10 description
+1
apps/client/src/features/user/queries/me-minimal.graphql
··· 3 3 id 4 4 email 5 5 name 6 + role 6 7 emailVerifiedAt 7 8 createdAt 8 9 }
+1 -1
apps/client/src/features/vacancies/components/VacancyForm.tsx
··· 48 48 49 49 if (!result.success) { 50 50 const newErrors: Partial<Record<keyof VacancyFormData, string>> = {}; 51 - result.error.errors.forEach((error) => { 51 + result.error.issues.forEach((error) => { 52 52 if (error.path.length > 0) { 53 53 newErrors[error.path[0] as keyof VacancyFormData] = error.message; 54 54 }
+1 -1
apps/client/src/features/vacancies/components/vacancy.schema.ts
··· 1 - import { z } from "zod"; 1 + import { z } from "zod/v4"; 2 2 3 3 export const vacancyFormSchema = z 4 4 .object({
+137 -171
apps/client/src/pages/CVViewPage.tsx
··· 1 1 import { ViewTransitionLink } from "@cv/routing"; 2 - import { Button, FormattedDateRange } from "@cv/ui"; 2 + import { Button } from "@cv/ui"; 3 + import { useCallback, useRef, useState } from "react"; 3 4 import { useParams } from "react-router-dom"; 4 5 import { 5 6 useCvQuery, 6 - useMeEducationQuery, 7 - useMeJobExperienceQuery, 7 + useGeneratePdfMutation, 8 + useRenderCvQuery, 8 9 } from "@/generated/graphql"; 9 - import "@/styles/print.css"; 10 + import { getServerUrl } from "@/utils/config"; 11 + 12 + type PdfState = 13 + | { status: "idle" } 14 + | { status: "generating" } 15 + | { status: "error"; message: string }; 16 + 17 + const POLL_INTERVAL_MS = 2000; 18 + const MAX_ATTEMPTS = 15; 19 + 20 + const usePdfDownload = (cvId: string) => { 21 + const [state, setState] = useState<PdfState>({ status: "idle" }); 22 + const abortRef = useRef<AbortController | null>(null); 23 + const generatePdf = useGeneratePdfMutation(); 24 + 25 + const download = useCallback(async () => { 26 + if (state.status === "generating") return; 27 + setState({ status: "generating" }); 28 + 29 + abortRef.current?.abort(); 30 + const abort = new AbortController(); 31 + abortRef.current = abort; 32 + 33 + try { 34 + await generatePdf.mutateAsync({ cvId }); 35 + 36 + const pdfUrl = `${getServerUrl()}/api/cv/${cvId}/pdf`; 37 + 38 + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { 39 + if (abort.signal.aborted) return; 40 + 41 + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); 42 + 43 + const res = await fetch(pdfUrl, { 44 + credentials: "include", 45 + signal: abort.signal, 46 + }); 47 + 48 + if (res.ok) { 49 + const blob = await res.blob(); 50 + const url = URL.createObjectURL(blob); 51 + const disposition = res.headers.get("content-disposition") ?? ""; 52 + const fileNameMatch = disposition.match(/filename="?(.+?)"?$/); 53 + const fileName = fileNameMatch?.[1] ?? "cv.pdf"; 54 + 55 + const anchor = document.createElement("a"); 56 + anchor.href = url; 57 + anchor.download = fileName; 58 + anchor.click(); 59 + URL.revokeObjectURL(url); 60 + 61 + setState({ status: "idle" }); 62 + return; 63 + } 64 + 65 + if (res.status !== 404) { 66 + setState({ 67 + status: "error", 68 + message: `Server responded with ${res.status}`, 69 + }); 70 + return; 71 + } 72 + } 73 + 74 + setState({ status: "error", message: "PDF generation timed out" }); 75 + } catch (err) { 76 + if (abort.signal.aborted) return; 77 + setState({ 78 + status: "error", 79 + message: err instanceof Error ? err.message : "Failed to generate PDF", 80 + }); 81 + } 82 + }, [cvId, generatePdf, state.status]); 83 + 84 + return { state, download }; 85 + }; 86 + 87 + const pdfButtonLabel = (state: PdfState): string => { 88 + if (state.status === "generating") return "Generating PDF..."; 89 + if (state.status === "error") return "Retry Download"; 90 + return "Download PDF"; 91 + }; 10 92 11 93 export const CVViewPage = () => { 12 94 const { id } = useParams<{ id: string }>(); 13 - const { data, isLoading } = useCvQuery({ id: id || "" }, { enabled: !!id }); 14 - const { data: jobExperienceData, isLoading: loadingJobs } = 15 - useMeJobExperienceQuery(); 16 - const { data: educationData, isLoading: loadingEducation } = 17 - useMeEducationQuery(); 95 + const iframeRef = useRef<HTMLIFrameElement>(null); 96 + 97 + const { data: cvData, isLoading: cvLoading } = useCvQuery( 98 + { id: id || "" }, 99 + { enabled: !!id }, 100 + ); 101 + const { data: renderData, isLoading: renderLoading } = useRenderCvQuery( 102 + { cvId: id || "" }, 103 + { enabled: !!id }, 104 + ); 105 + 106 + const pdf = usePdfDownload(id || ""); 107 + 108 + const handlePrint = useCallback(() => { 109 + iframeRef.current?.contentWindow?.print(); 110 + }, []); 18 111 19 - if (isLoading) { 112 + if (cvLoading || renderLoading) { 20 113 return ( 21 114 <div className="flex items-center justify-center min-h-screen"> 22 115 <div className="text-ctp-text">Loading CV...</div> ··· 24 117 ); 25 118 } 26 119 27 - if (!data?.cv) { 120 + if (!cvData?.cv) { 28 121 return ( 29 122 <div className="flex flex-col items-center justify-center min-h-screen"> 30 123 <h2 className="text-2xl font-bold text-ctp-text mb-4">CV Not Found</h2> ··· 35 128 ); 36 129 } 37 130 38 - const { cv } = data; 39 - const jobExperiences = 40 - jobExperienceData?.me?.experience?.edges?.map((edge) => edge.node) || []; 41 - const educations = 42 - educationData?.me?.educationHistory?.edges?.map((edge) => edge.node) || []; 131 + const { cv } = cvData; 43 132 44 133 return ( 45 - <div className="min-h-screen bg-ctp-base"> 134 + <div className="min-h-screen bg-ctp-base flex flex-col"> 46 135 <div className="no-print bg-ctp-surface0 border-b border-ctp-surface1 py-4"> 47 136 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between"> 48 137 <div> ··· 52 141 </p> 53 142 </div> 54 143 <div className="flex gap-3"> 55 - <Button onClick={() => window.print()}>Export PDF</Button> 56 - <ViewTransitionLink to={`/cvs/${cv.id}/edit`}> 57 - <Button variant="outline">Edit CV</Button> 144 + <Button 145 + onClick={pdf.download} 146 + disabled={pdf.state.status === "generating"} 147 + > 148 + {pdfButtonLabel(pdf.state)} 149 + </Button> 150 + <Button variant="outline" onClick={handlePrint}> 151 + Print 152 + </Button> 153 + <ViewTransitionLink to={`/cvs/${cv.id}/edit`} className="inline-flex items-center justify-center rounded-md font-medium h-10 px-4 py-2 border border-ctp-surface1 bg-transparent text-ctp-text hover:bg-ctp-surface0 transition-colors"> 154 + Edit CV 58 155 </ViewTransitionLink> 59 - <ViewTransitionLink to="/cvs"> 60 - <Button variant="ghost">Back to List</Button> 156 + <ViewTransitionLink to="/cvs" className="inline-flex items-center justify-center rounded-md font-medium h-10 px-4 py-2 text-ctp-text hover:bg-ctp-surface0 transition-colors"> 157 + Back to List 61 158 </ViewTransitionLink> 62 159 </div> 63 160 </div> 64 - </div> 65 - 66 - <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> 67 - <div className="cv-container bg-white rounded-lg shadow-lg p-8"> 68 - <div className="border-b border-gray-200 pb-6 mb-8 cv-entry"> 69 - <h1 className="text-4xl font-bold text-gray-900 mb-2"> 70 - {cv.title} 71 - </h1> 72 - {cv.introduction && ( 73 - <p className="text-lg text-gray-700 mt-4 whitespace-pre-line"> 74 - {cv.introduction} 75 - </p> 76 - )} 161 + {pdf.state.status === "error" && ( 162 + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-2"> 163 + <p className="text-sm text-ctp-red">{pdf.state.message}</p> 77 164 </div> 78 - 79 - {loadingEducation ? ( 80 - <div className="py-8 text-center text-gray-500"> 81 - Loading education history... 82 - </div> 83 - ) : educations.length === 0 ? ( 84 - <div className="py-8 text-center text-gray-500 mb-8"> 85 - No education history recorded yet. Add your educational background 86 - to see it here. 87 - </div> 88 - ) : ( 89 - <div className="space-y-8 mb-8"> 90 - <h2 className="text-2xl font-bold text-gray-900 border-b border-gray-200 pb-2"> 91 - Education 92 - </h2> 165 + )} 166 + </div> 93 167 94 - {educations.map((education) => ( 95 - <div 96 - key={education.id} 97 - className="cv-entry border-b border-gray-100 pb-6 last:border-0" 98 - > 99 - <div className="flex justify-between items-start mb-2"> 100 - <div> 101 - <h3 className="text-xl font-semibold text-gray-900"> 102 - {education.degree} 103 - </h3> 104 - <p className="text-lg text-gray-700"> 105 - {education.institution?.name || "Unknown Institution"} 106 - </p> 107 - {education.fieldOfStudy && ( 108 - <p className="text-sm text-gray-500 mt-1"> 109 - {education.fieldOfStudy} 110 - </p> 111 - )} 112 - </div> 113 - <div className="text-right text-sm text-gray-600"> 114 - <FormattedDateRange 115 - startDate={education.startDate} 116 - endDate={education.endDate} 117 - /> 118 - </div> 119 - </div> 120 - 121 - {education.description && ( 122 - <p className="text-gray-700 mt-3 whitespace-pre-line"> 123 - {education.description} 124 - </p> 125 - )} 126 - 127 - {education.skills && education.skills.length > 0 && ( 128 - <div className="mt-3"> 129 - <p className="text-sm font-semibold text-gray-700 mb-2"> 130 - Skills: 131 - </p> 132 - <div className="flex flex-wrap gap-2"> 133 - {education.skills.map((skill) => ( 134 - <span 135 - key={skill.id} 136 - className="px-3 py-1 bg-blue-50 text-blue-700 rounded-full text-sm" 137 - > 138 - {skill.name} 139 - </span> 140 - ))} 141 - </div> 142 - </div> 143 - )} 144 - </div> 145 - ))} 146 - </div> 147 - )} 148 - 149 - {loadingJobs ? ( 150 - <div className="py-8 text-center text-gray-500"> 151 - Loading job experience... 152 - </div> 153 - ) : jobExperiences.length === 0 ? ( 154 - <div className="py-8 text-center text-gray-500"> 155 - No job experience recorded yet. Add your work history to see it 156 - here. 157 - </div> 168 + <div className="flex-1 p-4 sm:p-6 lg:p-8"> 169 + <div className="max-w-5xl mx-auto h-full"> 170 + {renderData?.renderCV?.html ? ( 171 + <iframe 172 + ref={iframeRef} 173 + srcDoc={renderData.renderCV.html} 174 + sandbox="allow-same-origin allow-modals" 175 + title="CV Preview" 176 + className="w-full bg-white rounded-lg shadow-lg border-0" 177 + style={{ minHeight: "calc(100vh - 120px)" }} 178 + /> 158 179 ) : ( 159 - <div className="space-y-8"> 160 - <h2 className="text-2xl font-bold text-gray-900 border-b border-gray-200 pb-2"> 161 - Work Experience 162 - </h2> 163 - 164 - {jobExperiences.map((experience) => ( 165 - <div 166 - key={experience.id} 167 - className="cv-entry border-b border-gray-100 pb-6 last:border-0" 168 - > 169 - <div className="flex justify-between items-start mb-2"> 170 - <div> 171 - <h3 className="text-xl font-semibold text-gray-900"> 172 - {experience.role.name} 173 - </h3> 174 - <p className="text-lg text-gray-700"> 175 - {experience.company.name} 176 - </p> 177 - {experience.level && ( 178 - <p className="text-sm text-gray-500 mt-1"> 179 - {experience.level.name} 180 - </p> 181 - )} 182 - </div> 183 - <div className="text-right text-sm text-gray-600"> 184 - <FormattedDateRange 185 - startDate={experience.startDate} 186 - endDate={experience.endDate} 187 - /> 188 - </div> 189 - </div> 190 - 191 - {experience.description && ( 192 - <p className="text-gray-700 mt-3 mb-3 whitespace-pre-line"> 193 - {experience.description} 194 - </p> 195 - )} 196 - 197 - {experience.skills && experience.skills.length > 0 && ( 198 - <div className="mt-3"> 199 - <p className="text-sm font-semibold text-gray-700 mb-2"> 200 - Skills: 201 - </p> 202 - <div className="flex flex-wrap gap-2"> 203 - {experience.skills.map((skill) => ( 204 - <span 205 - key={skill.id} 206 - className="px-3 py-1 bg-blue-50 text-blue-700 rounded-full text-sm" 207 - > 208 - {skill.name} 209 - </span> 210 - ))} 211 - </div> 212 - </div> 213 - )} 214 - </div> 215 - ))} 180 + <div className="flex items-center justify-center h-64 text-ctp-subtext0"> 181 + No template content available. The selected template may not have a body defined. 216 182 </div> 217 183 )} 218 184 </div>
+4 -2
apps/client/src/pages/CVsPage.tsx
··· 10 10 useToast, 11 11 } from "@cv/ui"; 12 12 import { useQueryClient } from "@tanstack/react-query"; 13 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 13 14 import { useDeleteCvMutation, useMyCVsQuery } from "@/generated/graphql"; 14 15 15 16 export const CVsPage = () => { 16 - const { data, isLoading } = useMyCVsQuery({}); 17 + const profileId = useActiveProfileId(); 18 + const { data, isLoading } = useMyCVsQuery({ profileId }); 17 19 const { mutateAsync: deleteCV } = useDeleteCvMutation(); 18 20 const queryClient = useQueryClient(); 19 21 const { showSuccess, showError } = useToast(); ··· 58 60 ); 59 61 } 60 62 61 - const cvs = data?.me?.cvs?.edges?.map((edge) => edge.node) || []; 63 + const cvs = data?.profile?.cvs?.edges?.map((e) => e.node) ?? []; 62 64 63 65 return ( 64 66 <div className="space-y-6">
+3
apps/client/src/pages/CreateCVPage.tsx
··· 2 2 import { useQueryClient } from "@tanstack/react-query"; 3 3 import { useState } from "react"; 4 4 import { useNavigate } from "react-router-dom"; 5 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 5 6 import { useCreateCvMutation, useCvTemplatesQuery } from "@/generated/graphql"; 6 7 7 8 export const CreateCVPage = () => { 9 + const profileId = useActiveProfileId(); 8 10 const navigate = useNavigate(); 9 11 const queryClient = useQueryClient(); 10 12 const { data: templatesData, isLoading: templatesLoading } = ··· 35 37 try { 36 38 const result = await createCV({ 37 39 input: { 40 + profileId, 38 41 title: formData.title, 39 42 templateId: formData.templateId, 40 43 },
+4 -2
apps/client/src/pages/EditEducationPage.tsx
··· 1 1 import { PageHeader, Placeholder } from "@cv/ui"; 2 2 import { useNavigate, useParams } from "react-router-dom"; 3 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 3 4 import { EducationForm } from "@/features/education/components/EducationForm"; 4 5 import type { EducationFormData } from "@/features/education/components/education.schema"; 5 6 import { useMeEducationQuery } from "@/generated/graphql"; 6 7 7 8 export default function EditEducationPage() { 8 9 const { id } = useParams<{ id: string }>(); 10 + const profileId = useActiveProfileId(); 9 11 const navigate = useNavigate(); 10 - const { data, isPending } = useMeEducationQuery(); 12 + const { data, isPending } = useMeEducationQuery({ profileId }); 11 13 12 14 if (isPending) { 13 15 return ( ··· 21 23 ); 22 24 } 23 25 24 - const education = data?.me?.educationHistory?.edges 26 + const education = data?.profile?.educationHistory?.edges 25 27 ?.map((edge) => edge.node) 26 28 .find((edu) => edu.id === id); 27 29
+4 -2
apps/client/src/pages/EditJobExperiencePage.tsx
··· 1 1 import { PageHeader, Placeholder } from "@cv/ui"; 2 2 import { useNavigate, useParams } from "react-router-dom"; 3 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 3 4 import { JobExperienceForm } from "@/features/job-experience/components/JobExperienceForm"; 4 5 import type { JobExperienceFormData } from "@/features/job-experience/components/jobExperience.schema"; 5 6 import { useMeJobExperienceQuery } from "@/generated/graphql"; 6 7 7 8 export default function EditJobExperiencePage() { 8 9 const { id } = useParams<{ id: string }>(); 10 + const profileId = useActiveProfileId(); 9 11 const navigate = useNavigate(); 10 - const { data, isPending } = useMeJobExperienceQuery(); 12 + const { data, isPending } = useMeJobExperienceQuery({ profileId }); 11 13 12 14 if (isPending) { 13 15 return ( ··· 21 23 ); 22 24 } 23 25 24 - const experience = data?.me?.experience?.edges 26 + const experience = data?.profile?.experience?.edges 25 27 ?.map((edge) => edge.node) 26 28 .find((exp) => exp.id === id); 27 29
+5 -3
apps/client/src/pages/EducationPage.tsx
··· 1 1 import { Button, PageHeader, Placeholder, useToast } from "@cv/ui"; 2 2 import { useNavigate } from "react-router-dom"; 3 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 3 4 import { EducationTable } from "@/features/education/components"; 4 5 import type { MeEducationQuery } from "@/generated/graphql"; 5 6 import { useMeEducationQuery } from "@/generated/graphql"; 6 7 7 8 export default function EducationPage() { 8 - const { data, isPending: loading, error, refetch } = useMeEducationQuery(); 9 + const profileId = useActiveProfileId(); 10 + const { data, isPending: loading, error, refetch } = useMeEducationQuery({ profileId }); 9 11 const { showError } = useToast(); 10 12 const navigate = useNavigate(); 11 13 12 14 const handleEdit = ( 13 15 education: NonNullable< 14 - NonNullable<MeEducationQuery["me"]>["educationHistory"] 16 + NonNullable<MeEducationQuery["profile"]>["educationHistory"] 15 17 >["edges"][number]["node"], 16 18 ) => { 17 19 navigate(`/education/edit/${education.id}`); ··· 56 58 } 57 59 58 60 const educations = 59 - data?.me?.educationHistory?.edges?.map((edge) => edge.node) || []; 61 + data?.profile?.educationHistory?.edges?.map((edge) => edge.node) || []; 60 62 61 63 if (educations.length === 0) { 62 64 return (
+5 -3
apps/client/src/pages/JobExperiencePage.tsx
··· 1 1 import { Button, PageHeader, Placeholder, useToast } from "@cv/ui"; 2 2 import { useNavigate } from "react-router-dom"; 3 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 3 4 import { JobExperienceTable } from "@/features/job-experience/components"; 4 5 import type { MeJobExperienceQuery } from "@/generated/graphql"; 5 6 import { useMeJobExperienceQuery } from "@/generated/graphql"; 6 7 7 8 export default function JobExperiencePage() { 9 + const profileId = useActiveProfileId(); 8 10 const { 9 11 data, 10 12 isPending: loading, 11 13 error, 12 14 refetch, 13 - } = useMeJobExperienceQuery(); 15 + } = useMeJobExperienceQuery({ profileId }); 14 16 const { showError } = useToast(); 15 17 const navigate = useNavigate(); 16 18 17 19 const handleEdit = ( 18 20 experience: NonNullable< 19 - NonNullable<MeJobExperienceQuery["me"]>["experience"] 21 + NonNullable<MeJobExperienceQuery["profile"]>["experience"] 20 22 >["edges"][number]["node"], 21 23 ) => { 22 24 navigate(`/job-experience/edit/${experience.id}`); ··· 58 60 } 59 61 60 62 const jobExperiences = 61 - data?.me?.experience?.edges?.map((edge) => edge.node) || []; 63 + data?.profile?.experience?.edges?.map((edge) => edge.node) || []; 62 64 63 65 if (jobExperiences.length === 0) { 64 66 return (