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): add CV template selection and management

+459
+13
apps/client/src/features/cv-templates/mutations/create-cv.graphql
··· 1 + mutation CreateCV($input: CreateCVInput!) { 2 + createCV(input: $input) { 3 + id 4 + title 5 + template { 6 + id 7 + name 8 + description 9 + } 10 + createdAt 11 + updatedAt 12 + } 13 + }
+3
apps/client/src/features/cv-templates/mutations/delete-cv.graphql
··· 1 + mutation DeleteCV($id: String!) { 2 + deleteCV(id: $id) 3 + }
+13
apps/client/src/features/cv-templates/mutations/update-cv.graphql
··· 1 + mutation UpdateCV($id: String!, $input: UpdateCVInput!) { 2 + updateCV(id: $id, input: $input) { 3 + id 4 + title 5 + template { 6 + id 7 + name 8 + description 9 + } 10 + createdAt 11 + updatedAt 12 + } 13 + }
+9
apps/client/src/features/cv-templates/queries/cv-template.graphql
··· 1 + query CVTemplate($id: String!) { 2 + cvTemplate(id: $id) { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + }
+21
apps/client/src/features/cv-templates/queries/cv-templates.graphql
··· 1 + query CVTemplates($first: Int, $after: String) { 2 + cvTemplates(first: $first, after: $after) { 3 + edges { 4 + node { 5 + id 6 + name 7 + description 8 + createdAt 9 + updatedAt 10 + } 11 + cursor 12 + } 13 + pageInfo { 14 + hasNextPage 15 + hasPreviousPage 16 + startCursor 17 + endCursor 18 + } 19 + totalCount 20 + } 21 + }
+14
apps/client/src/features/cv-templates/queries/cv.graphql
··· 1 + query CV($id: String!) { 2 + cv(id: $id) { 3 + id 4 + title 5 + introduction 6 + createdAt 7 + updatedAt 8 + template { 9 + id 10 + name 11 + description 12 + } 13 + } 14 + }
+27
apps/client/src/features/cv-templates/queries/my-cvs.graphql
··· 1 + query MyCVs($first: Int, $after: String) { 2 + me { 3 + cvs(first: $first, after: $after) { 4 + edges { 5 + node { 6 + id 7 + title 8 + template { 9 + id 10 + name 11 + description 12 + } 13 + createdAt 14 + updatedAt 15 + } 16 + cursor 17 + } 18 + pageInfo { 19 + hasNextPage 20 + hasPreviousPage 21 + startCursor 22 + endCursor 23 + } 24 + totalCount 25 + } 26 + } 27 + }
+222
apps/client/src/pages/CVViewPage.tsx
··· 1 + import { Button, FormattedDateRange } from "@cv/ui"; 2 + import { Link, useParams } from "react-router-dom"; 3 + import { 4 + useCvQuery, 5 + useMeEducationQuery, 6 + useMeJobExperienceQuery, 7 + } from "@/generated/graphql"; 8 + 9 + export const CVViewPage = () => { 10 + const { id } = useParams<{ id: string }>(); 11 + const { data, isLoading } = useCvQuery({ id: id || "" }, { enabled: !!id }); 12 + const { data: jobExperienceData, isLoading: loadingJobs } = 13 + useMeJobExperienceQuery(); 14 + const { data: educationData, isLoading: loadingEducation } = 15 + useMeEducationQuery(); 16 + 17 + if (isLoading) { 18 + return ( 19 + <div className="flex items-center justify-center min-h-screen"> 20 + <div className="text-ctp-text">Loading CV...</div> 21 + </div> 22 + ); 23 + } 24 + 25 + if (!data?.cv) { 26 + return ( 27 + <div className="flex flex-col items-center justify-center min-h-screen"> 28 + <h2 className="text-2xl font-bold text-ctp-text mb-4">CV Not Found</h2> 29 + <Link to="/cvs"> 30 + <Button>Back to My CVs</Button> 31 + </Link> 32 + </div> 33 + ); 34 + } 35 + 36 + const { cv } = data; 37 + const jobExperiences = 38 + jobExperienceData?.me?.experience?.edges?.map((edge) => edge.node) || []; 39 + const educations = 40 + educationData?.me?.educationHistory?.edges?.map((edge) => edge.node) || []; 41 + 42 + return ( 43 + <div className="min-h-screen bg-ctp-base"> 44 + <div className="bg-ctp-surface0 border-b border-ctp-surface1 py-4"> 45 + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between"> 46 + <div> 47 + <h1 className="text-2xl font-bold text-ctp-text">{cv.title}</h1> 48 + <p className="text-sm text-ctp-subtext0"> 49 + Template: {cv.template.name} 50 + </p> 51 + </div> 52 + <div className="flex gap-3"> 53 + <Link to={`/cvs/${cv.id}/edit`}> 54 + <Button>Edit CV</Button> 55 + </Link> 56 + <Link to="/cvs"> 57 + <Button variant="ghost">Back to List</Button> 58 + </Link> 59 + </div> 60 + </div> 61 + </div> 62 + 63 + <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> 64 + <div className="bg-white rounded-lg shadow-lg p-8"> 65 + {/* CV Header */} 66 + <div className="border-b border-gray-200 pb-6 mb-8"> 67 + <h1 className="text-4xl font-bold text-gray-900 mb-2"> 68 + {cv.title} 69 + </h1> 70 + {cv.introduction && ( 71 + <p className="text-lg text-gray-700 mt-4 whitespace-pre-line"> 72 + {cv.introduction} 73 + </p> 74 + )} 75 + </div> 76 + 77 + {/* Education Section */} 78 + {loadingEducation ? ( 79 + <div className="py-8 text-center text-gray-500"> 80 + Loading education history... 81 + </div> 82 + ) : educations.length === 0 ? ( 83 + <div className="py-8 text-center text-gray-500 mb-8"> 84 + No education history recorded yet. Add your educational background 85 + to see it here. 86 + </div> 87 + ) : ( 88 + <div className="space-y-8 mb-8"> 89 + <h2 className="text-2xl font-bold text-gray-900 border-b border-gray-200 pb-2"> 90 + Education 91 + </h2> 92 + 93 + {educations.map((education) => ( 94 + <div 95 + key={education.id} 96 + className="border-b border-gray-100 pb-6 last:border-0" 97 + > 98 + <div className="flex justify-between items-start mb-2"> 99 + <div> 100 + <h3 className="text-xl font-semibold text-gray-900"> 101 + {education.degree} 102 + </h3> 103 + <p className="text-lg text-gray-700"> 104 + {education.institution?.name || "Unknown Institution"} 105 + </p> 106 + {education.fieldOfStudy && ( 107 + <p className="text-sm text-gray-500 mt-1"> 108 + {education.fieldOfStudy} 109 + </p> 110 + )} 111 + </div> 112 + <div className="text-right text-sm text-gray-600"> 113 + <FormattedDateRange 114 + startDate={education.startDate} 115 + endDate={education.endDate} 116 + /> 117 + </div> 118 + </div> 119 + 120 + {education.description && ( 121 + <p className="text-gray-700 mt-3 whitespace-pre-line"> 122 + {education.description} 123 + </p> 124 + )} 125 + 126 + {education.skills && education.skills.length > 0 && ( 127 + <div className="mt-3"> 128 + <p className="text-sm font-semibold text-gray-700 mb-2"> 129 + Skills: 130 + </p> 131 + <div className="flex flex-wrap gap-2"> 132 + {education.skills.map((skill) => ( 133 + <span 134 + key={skill.id} 135 + className="px-3 py-1 bg-blue-50 text-blue-700 rounded-full text-sm" 136 + > 137 + {skill.name} 138 + </span> 139 + ))} 140 + </div> 141 + </div> 142 + )} 143 + </div> 144 + ))} 145 + </div> 146 + )} 147 + 148 + {/* Job Experience Section */} 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> 158 + ) : ( 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="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 + ))} 216 + </div> 217 + )} 218 + </div> 219 + </div> 220 + </div> 221 + ); 222 + };
+137
apps/client/src/pages/CVsPage.tsx
··· 1 + import { 2 + Button, 3 + DeleteIcon, 4 + EditIcon, 5 + IconButton, 6 + PageHeader, 7 + Placeholder, 8 + } from "@cv/ui"; 9 + import { useQueryClient } from "@tanstack/react-query"; 10 + import { Link } from "react-router-dom"; 11 + import { useConfirmationModal } from "@/contexts/ConfirmationModalContext"; 12 + import { useToast } from "@/contexts/ToastContext"; 13 + import { useDeleteCvMutation, useMyCVsQuery } from "@/generated/graphql"; 14 + 15 + export const CVsPage = () => { 16 + const { data, isLoading } = useMyCVsQuery({}); 17 + const { mutateAsync: deleteCV } = useDeleteCvMutation(); 18 + const queryClient = useQueryClient(); 19 + const { showSuccess, showError } = useToast(); 20 + const { showConfirmation } = useConfirmationModal(); 21 + 22 + const handleDelete = async (id: string, title: string) => { 23 + const _confirmed = await showConfirmation({ 24 + title: "Delete CV", 25 + message: `Are you sure you want to delete "${title}"? This action cannot be undone.`, 26 + confirmText: "Delete", 27 + cancelText: "Cancel", 28 + variant: "danger", 29 + onConfirm: async () => { 30 + try { 31 + await deleteCV({ id }); 32 + 33 + // Invalidate and refetch CV queries 34 + await queryClient.invalidateQueries({ 35 + queryKey: ["MyCVs"], 36 + }); 37 + 38 + showSuccess("CV Deleted", "Your CV has been successfully deleted."); 39 + } catch (error) { 40 + console.error("Error deleting CV:", error); 41 + showError( 42 + "Failed to Delete CV", 43 + "There was an error deleting the CV. Please try again.", 44 + ); 45 + } 46 + }, 47 + }); 48 + }; 49 + 50 + if (isLoading) { 51 + return ( 52 + <div className="space-y-6"> 53 + <PageHeader 54 + title="My CVs" 55 + description="Create and manage your CVs using different templates" 56 + /> 57 + <Placeholder variant="loading" message="Loading your CVs..." /> 58 + </div> 59 + ); 60 + } 61 + 62 + const cvs = data?.me?.cvs?.edges?.map((edge) => edge.node) || []; 63 + 64 + return ( 65 + <div className="space-y-6"> 66 + <PageHeader 67 + title="My CVs" 68 + description="Create and manage your CVs using different templates" 69 + action={ 70 + <Link to="/cvs/create"> 71 + <Button>Create New CV</Button> 72 + </Link> 73 + } 74 + /> 75 + 76 + {cvs.length === 0 ? ( 77 + <Placeholder 78 + variant="empty" 79 + message="Get started by creating your first CV" 80 + > 81 + <Link to="/cvs/create"> 82 + <Button className="mt-4">Create Your First CV</Button> 83 + </Link> 84 + </Placeholder> 85 + ) : ( 86 + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 87 + {cvs.map((cv) => ( 88 + <div 89 + key={cv.id} 90 + className="bg-ctp-surface0 rounded-lg border border-ctp-surface1 p-6 hover:border-ctp-blue transition-colors" 91 + > 92 + <div className="flex items-start justify-between mb-4"> 93 + <div className="flex-1"> 94 + <h3 className="text-lg font-semibold text-ctp-text mb-1"> 95 + {cv.title} 96 + </h3> 97 + <p className="text-sm text-ctp-subtext0"> 98 + Template: {cv.template.name} 99 + </p> 100 + </div> 101 + <div className="flex items-center gap-2"> 102 + <Link to={`/cvs/${cv.id}/edit`}> 103 + <IconButton 104 + icon={<EditIcon />} 105 + label="Edit" 106 + variant="primary" 107 + showColorOnHover={true} 108 + /> 109 + </Link> 110 + <IconButton 111 + icon={<DeleteIcon />} 112 + label="Delete" 113 + variant="destructive" 114 + showColorOnHover={true} 115 + onClick={() => handleDelete(cv.id, cv.title)} 116 + /> 117 + </div> 118 + </div> 119 + 120 + <div className="text-xs text-ctp-subtext0"> 121 + Updated: {new Date(cv.updatedAt).toLocaleDateString()} 122 + </div> 123 + 124 + <div className="mt-4 pt-4 border-t border-ctp-surface1"> 125 + <Link to={`/cvs/${cv.id}`}> 126 + <Button variant="ghost" className="w-full"> 127 + View CV 128 + </Button> 129 + </Link> 130 + </div> 131 + </div> 132 + ))} 133 + </div> 134 + )} 135 + </div> 136 + ); 137 + };