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.

refactor(ui): extract inline SVGs and enhance button components

- Extract inline SVGs to separate icon components (EditIcon, DeleteIcon, ErrorIcon, LoadingIcon)
- Enhance Button component with leftIcon/rightIcon composition support
- Add IconButton component for icon-only actions
- Replace native HTML buttons with Button/IconButton components in JobExperienceCard
- Update cursor rules to prefer component library buttons over native HTML buttons
- Improve accessibility with proper ARIA labels and semantic elements

+176 -69
+3
.cursorrules
··· 35 35 - Prefer arrow function components for consistency 36 36 - Use destructuring for props: `const Component = ({ prop1, prop2 }) => ...` 37 37 - Prefer early returns in render methods to avoid deep nesting 38 + - **Prefer Button/IconButton components over native HTML buttons** 39 + - Use composition for icons with `leftIcon`/`rightIcon` props 40 + - Extract inline SVGs to separate icon components 38 41 39 42 ### NestJS/Backend 40 43 - Use dependency injection properly with regular imports (not `import type`)
+20
apps/client/src/components/icons/DeleteIcon.tsx
··· 1 + interface DeleteIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Delete icon component 7 + */ 8 + export default function DeleteIcon({ className = "w-4 h-4" }: DeleteIconProps) { 9 + return ( 10 + <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 11 + <title>Delete icon</title> 12 + <path 13 + strokeLinecap="round" 14 + strokeLinejoin="round" 15 + strokeWidth={2} 16 + d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" 17 + /> 18 + </svg> 19 + ); 20 + }
+20
apps/client/src/components/icons/EditIcon.tsx
··· 1 + interface EditIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Edit icon component 7 + */ 8 + export default function EditIcon({ className = "w-4 h-4" }: EditIconProps) { 9 + return ( 10 + <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 11 + <title>Edit icon</title> 12 + <path 13 + strokeLinecap="round" 14 + strokeLinejoin="round" 15 + strokeWidth={2} 16 + d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" 17 + /> 18 + </svg> 19 + ); 20 + }
+19
apps/client/src/components/icons/ErrorIcon.tsx
··· 1 + interface ErrorIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Error icon component 7 + */ 8 + export default function ErrorIcon({ className = "w-5 h-5" }: ErrorIconProps) { 9 + return ( 10 + <svg className={className} fill="currentColor" viewBox="0 0 20 20"> 11 + <title>Error icon</title> 12 + <path 13 + fillRule="evenodd" 14 + d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" 15 + clipRule="evenodd" 16 + /> 17 + </svg> 18 + ); 19 + }
+27
apps/client/src/components/icons/LoadingIcon.tsx
··· 1 + interface LoadingIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Loading spinner icon component 7 + */ 8 + export default function LoadingIcon({ className = "w-4 h-4" }: LoadingIconProps) { 9 + return ( 10 + <svg className={`${className} animate-spin`} fill="none" viewBox="0 0 24 24"> 11 + <title>Loading icon</title> 12 + <circle 13 + className="opacity-25" 14 + cx="12" 15 + cy="12" 16 + r="10" 17 + stroke="currentColor" 18 + strokeWidth="4" 19 + /> 20 + <path 21 + className="opacity-75" 22 + fill="currentColor" 23 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 24 + /> 25 + </svg> 26 + ); 27 + }
+4
apps/client/src/components/icons/index.ts
··· 1 1 export { default as CloseIcon } from "./CloseIcon"; 2 2 export { default as ToastIcon } from "./ToastIcon"; 3 + export { default as EditIcon } from "./EditIcon"; 4 + export { default as DeleteIcon } from "./DeleteIcon"; 5 + export { default as ErrorIcon } from "./ErrorIcon"; 6 + export { default as LoadingIcon } from "./LoadingIcon";
+14 -67
apps/client/src/features/job-experience/components/JobExperienceCard.tsx
··· 2 2 import ConfirmationModal from "@/components/ConfirmationModal"; 3 3 import type { MeJobExperienceQuery } from "@/generated/graphql"; 4 4 import { calculateDuration, formatDateRange } from "@/utils/dateUtils"; 5 + import IconButton from "@/ui/IconButton"; 6 + import { EditIcon, DeleteIcon, LoadingIcon } from "@/components/icons"; 5 7 6 8 interface JobExperienceCardProps { 7 9 experience: MeJobExperienceQuery["myEmploymentHistory"][0]; ··· 79 81 80 82 {/* Action buttons */} 81 83 <div className="flex gap-2 ml-4"> 82 - <button 83 - type="button" 84 + <IconButton 85 + icon={<EditIcon />} 86 + label="Edit experience" 84 87 onClick={handleEdit} 85 - className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" 86 - title="Edit experience" 87 - > 88 - <svg 89 - className="w-4 h-4" 90 - fill="none" 91 - stroke="currentColor" 92 - viewBox="0 0 24 24" 93 - > 94 - <title>Edit icon</title> 95 - <path 96 - strokeLinecap="round" 97 - strokeLinejoin="round" 98 - strokeWidth={2} 99 - d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" 100 - /> 101 - </svg> 102 - </button> 103 - <button 104 - type="button" 88 + variant="ghost" 89 + size="md" 90 + /> 91 + <IconButton 92 + icon={isDeleting ? <LoadingIcon /> : <DeleteIcon />} 93 + label={isDeleting ? "Deleting..." : "Delete experience"} 105 94 onClick={handleDeleteClick} 106 95 disabled={isDeleting} 107 - className={`p-2 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 ${ 108 - isDeleting 109 - ? "text-gray-400 cursor-not-allowed" 110 - : "text-gray-600 hover:text-red-600 hover:bg-red-50" 111 - }`} 112 - title={isDeleting ? "Deleting..." : "Delete experience"} 113 - > 114 - {isDeleting ? ( 115 - <svg 116 - className="w-4 h-4 animate-spin" 117 - fill="none" 118 - viewBox="0 0 24 24" 119 - > 120 - <title>Loading icon</title> 121 - <circle 122 - className="opacity-25" 123 - cx="12" 124 - cy="12" 125 - r="10" 126 - stroke="currentColor" 127 - strokeWidth="4" 128 - /> 129 - <path 130 - className="opacity-75" 131 - fill="currentColor" 132 - d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 133 - /> 134 - </svg> 135 - ) : ( 136 - <svg 137 - className="w-4 h-4" 138 - fill="none" 139 - stroke="currentColor" 140 - viewBox="0 0 24 24" 141 - > 142 - <title>Delete icon</title> 143 - <path 144 - strokeLinecap="round" 145 - strokeLinejoin="round" 146 - strokeWidth={2} 147 - d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" 148 - /> 149 - </svg> 150 - )} 151 - </button> 96 + variant="ghost" 97 + size="md" 98 + /> 152 99 </div> 153 100 </div> 154 101
+10 -2
apps/client/src/ui/Button.tsx
··· 1 1 import { cva, type VariantProps } from "class-variance-authority"; 2 - import type { ButtonHTMLAttributes, PropsWithChildren } from "react"; 2 + import type { ButtonHTMLAttributes, PropsWithChildren, ReactNode } from "react"; 3 3 4 4 const buttonVariants = cva( 5 5 "inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", ··· 30 30 ); 31 31 32 32 type ButtonProps = PropsWithChildren< 33 - ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants> 33 + ButtonHTMLAttributes<HTMLButtonElement> & 34 + VariantProps<typeof buttonVariants> & { 35 + leftIcon?: ReactNode; 36 + rightIcon?: ReactNode; 37 + } 34 38 >; 35 39 36 40 export default function Button({ ··· 38 42 className = "", 39 43 variant, 40 44 size, 45 + leftIcon, 46 + rightIcon, 41 47 ...props 42 48 }: ButtonProps) { 43 49 return ( 44 50 <button className={buttonVariants({ variant, size, className })} {...props}> 51 + {leftIcon && <span className="mr-2">{leftIcon}</span>} 45 52 {children} 53 + {rightIcon && <span className="ml-2">{rightIcon}</span>} 46 54 </button> 47 55 ); 48 56 }
+59
apps/client/src/ui/IconButton.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import type { ButtonHTMLAttributes, ReactNode } from "react"; 3 + 4 + const iconButtonVariants = cva( 5 + "inline-flex items-center justify-center rounded transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", 6 + { 7 + variants: { 8 + variant: { 9 + primary: 10 + "bg-ctp-mauve text-ctp-base hover:bg-ctp-mauve/90 focus:ring-ctp-mauve", 11 + secondary: 12 + "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface2 focus:ring-ctp-surface1", 13 + ghost: "text-ctp-text hover:bg-ctp-surface1 focus:ring-ctp-surface1", 14 + outline: 15 + "border border-ctp-surface0 text-ctp-text hover:bg-ctp-surface0 focus:ring-ctp-surface0", 16 + destructive: 17 + "bg-ctp-red text-ctp-base hover:bg-ctp-red/90 focus:ring-ctp-red", 18 + }, 19 + size: { 20 + sm: "p-1.5", 21 + md: "p-2", 22 + lg: "p-3", 23 + }, 24 + }, 25 + defaultVariants: { 26 + variant: "ghost", 27 + size: "md", 28 + }, 29 + }, 30 + ); 31 + 32 + type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & 33 + VariantProps<typeof iconButtonVariants> & { 34 + icon: ReactNode; 35 + label: string; 36 + }; 37 + 38 + /** 39 + * Icon-only button component for actions like edit, delete, etc. 40 + */ 41 + export default function IconButton({ 42 + icon, 43 + label, 44 + className = "", 45 + variant, 46 + size, 47 + ...props 48 + }: IconButtonProps) { 49 + return ( 50 + <button 51 + className={iconButtonVariants({ variant, size, className })} 52 + title={label} 53 + aria-label={label} 54 + {...props} 55 + > 56 + {icon} 57 + </button> 58 + ); 59 + }