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

Configure Feed

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

feat(client): add user profile components

+476
+30
apps/client/src/components/ProfileImage.tsx
··· 1 + import { formatName } from "@/utils/userUtils"; 2 + 3 + interface ProfileImageProps { 4 + user: { name: string }; 5 + size?: "sm" | "md" | "lg"; 6 + className?: string; 7 + } 8 + 9 + const sizeMap = { 10 + sm: "h-6 w-6 text-xs", 11 + md: "h-8 w-8 text-sm", 12 + lg: "h-10 w-10 text-base", 13 + }; 14 + 15 + export const ProfileImage = ({ 16 + user: { name }, 17 + size = "md", 18 + className = "", 19 + }: ProfileImageProps) => { 20 + const sizeClasses = sizeMap[size]; 21 + const initial = formatName(name); 22 + 23 + return ( 24 + <div 25 + className={`rounded-full bg-ctp-blue flex items-center justify-center text-ctp-base font-medium ${sizeClasses} ${className}`} 26 + > 27 + {initial} 28 + </div> 29 + ); 30 + };
+234
apps/client/src/components/Progress.tsx
··· 1 + import { raise } from "@cv/utils"; 2 + import { cva } from "class-variance-authority"; 3 + import { 4 + Children, 5 + createContext, 6 + type ReactElement, 7 + type ReactNode, 8 + useContext, 9 + useState, 10 + } from "react"; 11 + import { z } from "zod"; 12 + 13 + interface ProgressContextValue { 14 + currentStep: number; 15 + next: () => void; 16 + back: () => void; 17 + canGoNext: boolean; 18 + canGoBack: boolean; 19 + } 20 + 21 + const ProgressContext = createContext<ProgressContextValue | null>(null); 22 + 23 + export const useProgressContext = () => { 24 + return ( 25 + useContext(ProgressContext) ?? 26 + raise("useProgressContext must be used within a ProgressProvider") 27 + ); 28 + }; 29 + 30 + export const stepStateSchema = z.enum([ 31 + "selected", 32 + "completed", 33 + "pending", 34 + "error", 35 + ]); 36 + 37 + export const stepPropsSchema = z.object({ 38 + name: z.string().min(1, "Step name is required"), 39 + children: z.any().optional(), 40 + index: z.number().int().nonnegative().optional(), 41 + state: stepStateSchema.optional(), 42 + }); 43 + 44 + export type StepProps = z.infer<typeof stepPropsSchema>; 45 + export type StepState = z.infer<typeof stepStateSchema>; 46 + 47 + const validateStepProps = (props: unknown): StepProps | null => { 48 + const result = stepPropsSchema.safeParse(props); 49 + if (result.success) { 50 + return result.data; 51 + } 52 + return null; 53 + }; 54 + 55 + const getStepPropsFromElement = (element: ReactElement): StepProps | null => { 56 + if ( 57 + element && 58 + typeof element === "object" && 59 + "props" in element && 60 + element.props && 61 + typeof element.props === "object" 62 + ) { 63 + return validateStepProps(element.props); 64 + } 65 + return null; 66 + }; 67 + 68 + const stepContainerVariants = cva("flex items-center space-x-2", { 69 + variants: { 70 + state: { 71 + selected: "text-ctp-blue", 72 + completed: "text-ctp-green", 73 + pending: "text-ctp-subtext0", 74 + error: "text-ctp-red", 75 + }, 76 + }, 77 + defaultVariants: { 78 + state: "pending", 79 + }, 80 + }); 81 + 82 + const stepIndicatorVariants = cva( 83 + "h-8 w-8 rounded-full flex items-center justify-center", 84 + { 85 + variants: { 86 + state: { 87 + selected: "bg-ctp-blue text-ctp-base", 88 + completed: "bg-ctp-green text-ctp-base", 89 + pending: "bg-ctp-surface0", 90 + error: "bg-ctp-red text-ctp-base", 91 + }, 92 + }, 93 + defaultVariants: { 94 + state: "pending", 95 + }, 96 + }, 97 + ); 98 + 99 + interface StepComponentProps { 100 + name: string; 101 + children?: ReactNode; 102 + } 103 + 104 + interface StepRenderProps { 105 + name: string; 106 + index: number; 107 + state: StepState; 108 + } 109 + 110 + const Step = (_props: StepComponentProps) => { 111 + // This component is just a marker - Progress will render it 112 + return null; 113 + }; 114 + 115 + Step.displayName = "Progress.Step"; 116 + 117 + const StepRenderer = ({ name, index, state }: StepRenderProps) => { 118 + const stepNumber = index + 1; 119 + 120 + return ( 121 + <div className={stepContainerVariants({ state })}> 122 + <div className={stepIndicatorVariants({ state })}>{stepNumber}</div> 123 + <span className="font-medium">{name}</span> 124 + </div> 125 + ); 126 + }; 127 + 128 + const ConnectorLine = ({ isCompleted }: { isCompleted: boolean }) => ( 129 + <div 130 + className={`h-1 w-16 ${isCompleted ? "bg-ctp-green" : "bg-ctp-surface0"}`} 131 + /> 132 + ); 133 + 134 + interface ProgressProps { 135 + initialStep?: number; 136 + onNext?: () => void; 137 + onBack?: () => void; 138 + children: ReactNode; 139 + } 140 + 141 + export const Progress = ({ 142 + initialStep = 0, 143 + onNext, 144 + onBack, 145 + children, 146 + }: ProgressProps) => { 147 + const [currentStep, setCurrentStep] = useState(initialStep); 148 + 149 + const steps = Children.toArray(children).filter( 150 + (child): child is ReactElement => 151 + child !== null && 152 + typeof child === "object" && 153 + "type" in child && 154 + child.type === Step, 155 + ); 156 + 157 + const totalSteps = steps.length; 158 + const canGoNext = currentStep < totalSteps - 1; 159 + const canGoBack = currentStep > 0; 160 + 161 + const handleNext = () => { 162 + if (canGoNext) { 163 + setCurrentStep((prev) => prev + 1); 164 + onNext?.(); 165 + } 166 + }; 167 + 168 + const handleBack = () => { 169 + if (canGoBack) { 170 + setCurrentStep((prev) => prev - 1); 171 + onBack?.(); 172 + } 173 + }; 174 + 175 + const currentStepElement = steps[currentStep]; 176 + const currentStepProps = currentStepElement 177 + ? getStepPropsFromElement(currentStepElement) 178 + : null; 179 + const currentStepContent = currentStepProps?.children ?? null; 180 + 181 + return ( 182 + <ProgressContext.Provider 183 + value={{ 184 + currentStep, 185 + next: handleNext, 186 + back: handleBack, 187 + canGoNext, 188 + canGoBack, 189 + }} 190 + > 191 + <div> 192 + <div className="mb-8"> 193 + <div className="flex items-center justify-center space-x-4"> 194 + {steps.map((stepElement, index) => { 195 + const stepProps = getStepPropsFromElement(stepElement); 196 + if (!stepProps) { 197 + console.warn(`Invalid step props at index ${index}`); 198 + return null; 199 + } 200 + 201 + const isCurrent = index === currentStep; 202 + const isCompleted = index < currentStep; 203 + const stepState: StepState = isCurrent 204 + ? "selected" 205 + : isCompleted 206 + ? "completed" 207 + : "pending"; 208 + 209 + return ( 210 + <div 211 + key={`step-${stepProps.name}-${index}`} 212 + className="flex items-center" 213 + > 214 + <StepRenderer 215 + name={stepProps.name} 216 + index={index} 217 + state={stepState} 218 + /> 219 + {index < totalSteps - 1 && ( 220 + <ConnectorLine isCompleted={isCompleted} /> 221 + )} 222 + </div> 223 + ); 224 + })} 225 + </div> 226 + </div> 227 + 228 + {currentStepContent && <div>{currentStepContent}</div>} 229 + </div> 230 + </ProgressContext.Provider> 231 + ); 232 + }; 233 + 234 + Progress.Step = Step;
+67
apps/client/src/components/Tooltip.tsx
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + 3 + interface TooltipProps { 4 + children: React.ReactNode; 5 + position?: "left" | "right" | "top" | "bottom"; 6 + triggerRef: React.RefObject<HTMLElement>; 7 + } 8 + 9 + export const Tooltip = ({ 10 + children, 11 + position = "right", 12 + triggerRef, 13 + }: TooltipProps) => { 14 + const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 }); 15 + const tooltipRef = useRef<HTMLDivElement>(null); 16 + 17 + useEffect(() => { 18 + const trigger = triggerRef.current; 19 + const tooltip = tooltipRef.current; 20 + if (!trigger) { 21 + return; 22 + } 23 + if (!tooltip) { 24 + return; 25 + } 26 + 27 + const triggerRect = trigger.getBoundingClientRect(); 28 + const tooltipRect = tooltip.getBoundingClientRect(); 29 + 30 + let top = 0; 31 + let left = 0; 32 + 33 + switch (position) { 34 + case "right": 35 + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; 36 + left = triggerRect.right + 8; 37 + break; 38 + case "left": 39 + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; 40 + left = triggerRect.left - tooltipRect.width - 8; 41 + break; 42 + case "top": 43 + top = triggerRect.top - tooltipRect.height - 8; 44 + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; 45 + break; 46 + case "bottom": 47 + top = triggerRect.bottom + 8; 48 + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; 49 + break; 50 + } 51 + 52 + setTooltipPosition({ top, left }); 53 + }, [position, triggerRef]); 54 + 55 + return ( 56 + <div 57 + ref={tooltipRef} 58 + className="fixed z-50 pointer-events-none transition-opacity duration-200" 59 + style={{ 60 + top: `${tooltipPosition.top}px`, 61 + left: `${tooltipPosition.left}px`, 62 + }} 63 + > 64 + {children} 65 + </div> 66 + ); 67 + };
+145
apps/client/src/components/UserProfileDrawer.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { ProfileImage } from "./ProfileImage"; 4 + import { ServerStatusIndicator } from "./ServerStatusIndicator"; 5 + 6 + type UseHoverMenuProps = { 7 + hoverDelay?: number; 8 + }; 9 + 10 + const useHoverMenu = ({ hoverDelay = 200 }: UseHoverMenuProps) => { 11 + const [isOpen, setIsOpen] = useState(false); 12 + const [hoverTimeout, setHoverTimeout] = useState<number | null>(null); 13 + 14 + // Cleanup timeout on unmount 15 + useEffect(() => { 16 + return () => { 17 + if (hoverTimeout) { 18 + clearTimeout(hoverTimeout); 19 + } 20 + }; 21 + }, [hoverTimeout]); 22 + 23 + const handleMouseEnter = () => { 24 + if (hoverTimeout) { 25 + clearTimeout(hoverTimeout); 26 + setHoverTimeout(null); 27 + } 28 + setIsOpen(true); 29 + }; 30 + 31 + const handleMouseLeave = () => { 32 + const timeout = setTimeout(() => { 33 + setIsOpen(false); 34 + }, hoverDelay); 35 + setHoverTimeout(timeout); 36 + }; 37 + 38 + return { 39 + isOpen, 40 + handleMouseEnter, 41 + handleMouseLeave, 42 + }; 43 + }; 44 + 45 + type UserProfileDrawerProps = { 46 + user: { 47 + name: string; 48 + }; 49 + onLogout: () => void; 50 + hoverDelay?: number; 51 + onProfileClick?: () => void; 52 + }; 53 + 54 + export const UserProfileDrawer = ({ 55 + user, 56 + onLogout, 57 + hoverDelay = 200, 58 + onProfileClick = () => { 59 + window.location.href = "/profile"; 60 + }, 61 + }: UserProfileDrawerProps) => { 62 + const { isOpen, handleMouseEnter, handleMouseLeave } = useHoverMenu({ 63 + hoverDelay, 64 + }); 65 + 66 + return ( 67 + <div className="relative"> 68 + <button 69 + type="button" 70 + className="relative flex items-center gap-3 cursor-pointer p-3 rounded-lg hover:bg-ctp-surface0 transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-blue focus:ring-inset" 71 + onClick={onProfileClick} 72 + onKeyDown={(e) => { 73 + if (e.key === "Enter" || e.key === " ") { 74 + e.preventDefault(); 75 + onProfileClick(); 76 + } 77 + }} 78 + onMouseEnter={handleMouseEnter} 79 + onMouseLeave={handleMouseLeave} 80 + aria-expanded={isOpen} 81 + aria-haspopup="menu" 82 + aria-label="User profile menu" 83 + > 84 + {/* Profile Image */} 85 + <ProfileImage user={user} size="md" /> 86 + 87 + {/* User Info */} 88 + <div className="hidden md:block"> 89 + <div className="flex items-center gap-2"> 90 + <span className="text-sm font-medium text-ctp-text"> 91 + {user.name} 92 + </span> 93 + <ServerStatusIndicator compact /> 94 + </div> 95 + </div> 96 + </button> 97 + 98 + {/* Dropdown Menu */} 99 + {isOpen && ( 100 + <div 101 + className="absolute right-0 top-full mt-2 w-48 bg-ctp-surface0 rounded-lg shadow-lg border border-ctp-surface1 py-2 z-50" 102 + role="menu" 103 + aria-hidden={!isOpen} 104 + onMouseEnter={handleMouseEnter} 105 + onMouseLeave={handleMouseLeave} 106 + > 107 + <Link 108 + to="/profile" 109 + role="menuitem" 110 + className="flex items-center gap-3 px-4 py-2 text-sm text-ctp-text hover:bg-ctp-surface1 transition-colors" 111 + > 112 + <ProfileImage user={user} size="sm" /> 113 + <div> 114 + <div className="font-medium">{user.name}</div> 115 + <div className="text-xs text-ctp-subtext0">View Profile</div> 116 + </div> 117 + </Link> 118 + 119 + <div className="border-t border-ctp-surface1 my-2"></div> 120 + 121 + <a 122 + href={import.meta.env["VITE_DOCS_URL"] || "http://localhost:3001"} 123 + target="_blank" 124 + rel="noopener noreferrer" 125 + role="menuitem" 126 + className="flex items-center gap-3 px-4 py-2 text-sm text-ctp-text hover:bg-ctp-surface1 transition-colors" 127 + > 128 + <span className="text-lg">📚</span> 129 + <span>Documentation</span> 130 + </a> 131 + 132 + <button 133 + type="button" 134 + onClick={onLogout} 135 + role="menuitem" 136 + className="w-full flex items-center gap-3 px-4 py-2 text-sm text-ctp-red hover:bg-ctp-surface1 transition-colors text-left" 137 + > 138 + <span className="text-lg">🚪</span> 139 + <span>Logout</span> 140 + </button> 141 + </div> 142 + )} 143 + </div> 144 + ); 145 + };