mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 09:24:54 +10:00
feat:icon colors (#2928)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,18 +1,43 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { type ColorResult, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
|
||||
import ReactColorColorful from "@uiw/react-color-colorful";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
const presetColors = [
|
||||
"rgba(0, 0, 0, 1)",
|
||||
"rgba(231, 0, 11, 1)",
|
||||
"rgba(245, 73, 0, 1)",
|
||||
"rgba(225, 113, 0, 1)",
|
||||
"rgba(208, 135, 0, 1)",
|
||||
"rgba(94, 165, 0, 1)",
|
||||
"rgba(0, 166, 62, 1)",
|
||||
"rgba(0, 153, 102, 1)",
|
||||
"rgba(0, 146, 184, 1)",
|
||||
"rgba(0, 132, 209, 1)",
|
||||
"rgba(21, 93, 252, 1)",
|
||||
"rgba(79, 57, 246, 1)",
|
||||
"rgba(127, 34, 254, 1)",
|
||||
"rgba(200, 0, 222, 1)",
|
||||
"rgba(230, 0, 118, 1)",
|
||||
"rgba(69, 85, 108, 1)",
|
||||
] as const;
|
||||
|
||||
type ColorPickerProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps) {
|
||||
export function ColorPicker({ value, defaultValue, onChange, trigger, children }: ColorPickerProps) {
|
||||
const [currentValue, setCurrentValue] = useControlledState<string>({
|
||||
value,
|
||||
defaultValue,
|
||||
@@ -28,15 +53,56 @@ export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground transition-opacity hover:opacity-60"
|
||||
style={{ backgroundColor: currentValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
{trigger ?? (
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
style={{ backgroundColor: currentValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
|
||||
<PopoverContent className="max-w-fit rounded-md p-2">
|
||||
<ReactColorColorful color={color} onChange={onColorChange} />
|
||||
<PopoverContent align="start" className="min-w-xs">
|
||||
{children && (
|
||||
<>
|
||||
{children}
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Presets</Trans>
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-8 gap-3 rounded bg-muted p-3">
|
||||
{presetColors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={t`Use color ${color}`}
|
||||
aria-pressed={currentValue === color}
|
||||
onClick={() => setCurrentValue(color)}
|
||||
className={cn(
|
||||
"size-5 shrink-0 cursor-pointer rounded-full transition-all hover:scale-105 focus-visible:outline-hidden",
|
||||
currentValue === color && "border border-foreground/60",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Custom</Trans>
|
||||
</span>
|
||||
|
||||
<div className="rounded bg-muted p-3 *:w-full! [&_.w-color-alpha]:mt-4! [&_.w-color-alpha]:h-4! [&_.w-color-alpha>div]:rounded-full! [&_.w-color-hue]:mt-4! [&_.w-color-hue]:h-4! [&_.w-color-hue]:rounded-full! [&_.w-color-saturation]:h-36! [&_.w-color-saturation]:rounded-[calc(var(--radius-lg)-0.25rem)]!">
|
||||
<ReactColorColorful color={color} onChange={onColorChange} />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -176,26 +176,3 @@
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.color_picker {
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-muted);
|
||||
padding: 0.5rem;
|
||||
|
||||
> :global(*) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:global(.w-color-saturation) {
|
||||
height: 8.5rem !important;
|
||||
border-radius: calc(var(--radius-lg) - 0.25rem) !important;
|
||||
}
|
||||
|
||||
:global(.w-color-hue),
|
||||
:global(.w-color-alpha) {
|
||||
height: 0.75rem !important;
|
||||
margin-top: 0.5rem !important;
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ import {
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { type ColorResult, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
|
||||
import ReactColorColorful from "@uiw/react-color-colorful";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
@@ -69,37 +67,18 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/utils/locale";
|
||||
import { sanitizeHtml } from "@/utils/sanitize";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { Toggle } from "../ui/toggle";
|
||||
import { ColorPicker } from "./color-picker";
|
||||
import styles from "./rich-input.module.css";
|
||||
|
||||
const defaultTextColor = "rgba(0, 0, 0, 1)";
|
||||
|
||||
const richTextColorOptions = [
|
||||
"rgba(0, 0, 0, 1)",
|
||||
"rgba(231, 0, 11, 1)",
|
||||
"rgba(245, 73, 0, 1)",
|
||||
"rgba(225, 113, 0, 1)",
|
||||
"rgba(208, 135, 0, 1)",
|
||||
"rgba(94, 165, 0, 1)",
|
||||
"rgba(0, 166, 62, 1)",
|
||||
"rgba(0, 153, 102, 1)",
|
||||
"rgba(0, 146, 184, 1)",
|
||||
"rgba(0, 132, 209, 1)",
|
||||
"rgba(21, 93, 252, 1)",
|
||||
"rgba(79, 57, 246, 1)",
|
||||
"rgba(127, 34, 254, 1)",
|
||||
"rgba(200, 0, 222, 1)",
|
||||
"rgba(230, 0, 118, 1)",
|
||||
"rgba(69, 85, 108, 1)",
|
||||
] as const;
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
@@ -461,103 +440,65 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
<HighlighterCircleIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className={cn("rounded-none px-2", state.textColor && "bg-muted text-foreground")}
|
||||
title={t`Text Color`}
|
||||
disabled={!state.canTextColor}
|
||||
>
|
||||
<span className="flex flex-col items-center leading-none">
|
||||
<span className="text-xs font-semibold">A</span>
|
||||
<span
|
||||
className="mt-0.5 h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: state.textColor ?? "currentColor" }}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContent sideOffset={8} className="w-80 gap-3 rounded-xl p-3 shadow-xl">
|
||||
<PopoverHeader className="flex-row items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 text-sm font-semibold shadow-xs"
|
||||
style={{ color: state.textColor ?? "currentColor" }}
|
||||
<ColorPicker
|
||||
defaultValue={defaultTextColor}
|
||||
value={state.textColor ?? undefined}
|
||||
onChange={state.setTextColor}
|
||||
trigger={
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className={cn("rounded-none px-2", state.textColor && "bg-muted text-foreground")}
|
||||
title={t`Text Color`}
|
||||
disabled={!state.canTextColor}
|
||||
>
|
||||
A
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<PopoverTitle>
|
||||
<Trans>Text Color</Trans>
|
||||
</PopoverTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans comment="Preset or custom shade refer to the color picker">
|
||||
Choose a preset or custom shade.
|
||||
</Trans>
|
||||
<span className="flex flex-col items-center leading-none">
|
||||
<span className="text-xs font-semibold">A</span>
|
||||
<span
|
||||
className="mt-0.5 h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: state.textColor ?? "currentColor" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={state.unsetTextColor}
|
||||
disabled={!state.textColor}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PopoverHeader className="flex-row items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 text-sm font-semibold shadow-xs"
|
||||
style={{ color: state.textColor ?? "currentColor" }}
|
||||
>
|
||||
<Trans comment="Clear the text color">Clear</Trans>
|
||||
</Button>
|
||||
</PopoverHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Presets</Trans>
|
||||
A
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-8 gap-2 rounded-lg bg-muted/40 p-2">
|
||||
{richTextColorOptions.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={cn(
|
||||
"size-7 rounded-full border border-border shadow-xs ring-offset-background transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden",
|
||||
state.textColor === color && "ring-2 ring-ring ring-offset-2",
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
aria-label={t`Use color ${color}`}
|
||||
aria-pressed={state.textColor === color}
|
||||
onClick={() => state.setTextColor(color)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<PopoverTitle>
|
||||
<Trans>Text Color</Trans>
|
||||
</PopoverTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans comment="Preset or custom shade refer to the color picker">
|
||||
Choose a preset or custom shade.
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Custom</Trans>
|
||||
</span>
|
||||
|
||||
<div className={styles.color_picker}>
|
||||
<ReactColorColorful
|
||||
color={rgbaStringToHsva(state.textColor ?? defaultTextColor)}
|
||||
onChange={(color: ColorResult) => {
|
||||
state.setTextColor(hsvaToRgbaString(color.hsva));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={state.unsetTextColor}
|
||||
disabled={!state.textColor}
|
||||
>
|
||||
<Trans comment="Clear the text color">Clear</Trans>
|
||||
</Button>
|
||||
</PopoverHeader>
|
||||
</ColorPicker>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function InterestsItem({ className, ...item }: InterestsItemProps) {
|
||||
<div className={cn("interests-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header interests-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon" />
|
||||
<PageIcon icon={item.icon} color={item.iconColor} className="section-item-icon interests-item-icon" />
|
||||
<strong className="section-item-title interests-item-name">{item.name}</strong>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ProfilesItem({ className, ...item }: ProfilesItemProps) {
|
||||
<div className={cn("profiles-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header profiles-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon" />
|
||||
<PageIcon icon={item.icon} color={item.iconColor} className="section-item-icon profiles-item-icon" />
|
||||
<LinkedTitle
|
||||
title={item.network}
|
||||
website={item.website}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SkillsItem({ className, ...item }: SkillsItemProps) {
|
||||
<div className={cn("skills-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon skills-item-icon" />
|
||||
<PageIcon icon={item.icon} color={item.iconColor} className="section-item-icon skills-item-icon" />
|
||||
<strong className="section-item-title skills-item-name">{item.name}</strong>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/utils/style";
|
||||
|
||||
import type { ExtendedIconProps } from "../preview";
|
||||
|
||||
export function PageIcon({ icon, className }: { icon: string; className?: string }) {
|
||||
export function PageIcon({ icon, color, className }: { icon: string; color?: string; className?: string }) {
|
||||
const iconContext = use<ExtendedIconProps>(IconContext);
|
||||
|
||||
if (!icon || iconContext.hidden) return null;
|
||||
@@ -13,7 +13,7 @@ export function PageIcon({ icon, className }: { icon: string; className?: string
|
||||
return (
|
||||
<i
|
||||
className={cn("ph shrink-0", `ph-${icon}`, className)}
|
||||
style={{ fontSize: `${iconContext.size}px`, color: iconContext.color }}
|
||||
style={{ fontSize: `${iconContext.size}px`, color: color || iconContext.color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ describe("useResumeStore — updateResumeData", () => {
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
@@ -246,7 +247,16 @@ describe("useResumeStore — edge cases", () => {
|
||||
it("can remove all items from a section", () => {
|
||||
const resume = makeResume();
|
||||
resume.data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "JS",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
];
|
||||
useResumeStore.getState().initialize(resume);
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ function ComboboxContent({
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-60 origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"group/combobox-content relative max-h-(--available-height) max-w-fit min-w-(--anchor-width) origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -44,7 +44,7 @@ function ContextMenuContent({
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"z-50 max-h-(--available-height) max-w-fit min-w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -37,7 +37,8 @@ function DropdownMenuContent({
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
|
||||
"z-50 max-h-(--available-height) max-w-fit min-w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,12 +9,14 @@ import { useForm, useFormContext, useFormState } from "react-hook-form";
|
||||
import type { DialogProps } from "@/dialogs/store";
|
||||
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { interestItemSchema } from "@/schema/resume/data";
|
||||
@@ -36,6 +38,7 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
iconColor: data?.item?.iconColor ?? "",
|
||||
name: data?.item?.name ?? "",
|
||||
keywords: data?.item?.keywords ?? [],
|
||||
},
|
||||
@@ -89,6 +92,7 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
icon: data.item.icon,
|
||||
iconColor: data.item.iconColor,
|
||||
name: data.item.name,
|
||||
keywords: data.item.keywords,
|
||||
},
|
||||
@@ -150,7 +154,11 @@ function InterestForm() {
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
<IconPicker
|
||||
{...field}
|
||||
popoverProps={{ modal: true }}
|
||||
className="rounded-r-none border-e-0 border-input"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -165,11 +173,37 @@ function InterestForm() {
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormControl render={<Input className="rounded-s-none rounded-e-none" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="iconColor"
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
trigger={
|
||||
<PopoverTrigger className="h-9 rounded-e border-y border-e border-input px-2">
|
||||
<div
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
style={{ backgroundColor: field.value ?? "currentColor" }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AtIcon, PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useForm, useFormContext, useFormState } from "react-hook-form";
|
||||
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
@@ -14,6 +15,7 @@ import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTit
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
import { PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type DialogProps, useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
@@ -37,6 +39,7 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
iconColor: data?.item?.iconColor ?? "",
|
||||
network: data?.item?.network ?? "",
|
||||
username: data?.item?.username ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
@@ -92,6 +95,7 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
icon: data.item.icon,
|
||||
iconColor: data.item.iconColor,
|
||||
network: data.item.network,
|
||||
username: data.item.username,
|
||||
website: data.item.website,
|
||||
@@ -154,7 +158,11 @@ function ProfileForm() {
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
<IconPicker
|
||||
{...field}
|
||||
popoverProps={{ modal: true }}
|
||||
className="rounded-r-none border-e-0 border-input"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -169,11 +177,37 @@ function ProfileForm() {
|
||||
<FormLabel>
|
||||
<Trans>Network</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormControl render={<Input className="rounded-s-none rounded-e-none" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="iconColor"
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
trigger={
|
||||
<PopoverTrigger className="h-9 rounded-e border-y border-e border-input px-2">
|
||||
<div
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
style={{ backgroundColor: field.value ?? "currentColor" }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -10,12 +10,14 @@ import { useForm, useFormContext, useFormState } from "react-hook-form";
|
||||
import type { DialogProps } from "@/dialogs/store";
|
||||
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
@@ -38,6 +40,7 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
iconColor: data?.item?.iconColor ?? "",
|
||||
name: data?.item?.name ?? "",
|
||||
proficiency: data?.item?.proficiency ?? "",
|
||||
level: data?.item?.level ?? 0,
|
||||
@@ -93,6 +96,7 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
icon: data.item.icon,
|
||||
iconColor: data.item.iconColor,
|
||||
name: data.item.name,
|
||||
proficiency: data.item.proficiency,
|
||||
level: data.item.level,
|
||||
@@ -156,7 +160,11 @@ function SkillForm() {
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
<IconPicker
|
||||
{...field}
|
||||
popoverProps={{ modal: true }}
|
||||
className="rounded-r-none border-e-0 border-input"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -171,11 +179,37 @@ function SkillForm() {
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormControl render={<Input className="rounded-s-none rounded-e-none" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="iconColor"
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
trigger={
|
||||
<PopoverTrigger className="h-9 rounded-e border-y border-e border-input px-2">
|
||||
<div
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
style={{ backgroundColor: field.value ?? "currentColor" }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("DialogStore — openDialog", () => {
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "star",
|
||||
iconColor: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
|
||||
@@ -280,6 +280,7 @@ export class JSONResumeImporter {
|
||||
id: generateId(),
|
||||
hidden: false,
|
||||
icon: "star",
|
||||
iconColor: "",
|
||||
name: skill.name || "",
|
||||
proficiency: skill.level || "",
|
||||
level: parseLevel(skill.level),
|
||||
@@ -314,6 +315,7 @@ export class JSONResumeImporter {
|
||||
id: generateId(),
|
||||
hidden: false,
|
||||
icon: "star",
|
||||
iconColor: "",
|
||||
name: interest.name || "",
|
||||
keywords: interest.keywords || [],
|
||||
})),
|
||||
@@ -420,6 +422,7 @@ export class JSONResumeImporter {
|
||||
id: generateId(),
|
||||
hidden: false,
|
||||
icon: getNetworkIcon(profile.network),
|
||||
iconColor: "",
|
||||
network: profile.network || "",
|
||||
username: profile.username || "",
|
||||
website: createUrl(profile.url, profile.username || profile.network),
|
||||
|
||||
@@ -456,6 +456,7 @@ export class ReactiveResumeV4JSONImporter {
|
||||
id: item.id ?? generateId(),
|
||||
hidden: !(item.visible ?? true),
|
||||
icon: item.icon ?? "",
|
||||
iconColor: "",
|
||||
network: item.network!,
|
||||
username: item.username ?? "",
|
||||
website: {
|
||||
@@ -535,6 +536,7 @@ export class ReactiveResumeV4JSONImporter {
|
||||
id: item.id ?? generateId(),
|
||||
hidden: !(item.visible ?? true),
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: item.name!,
|
||||
proficiency: item.description ?? "",
|
||||
level: clampLevel(item.level ?? 0),
|
||||
@@ -565,6 +567,7 @@ export class ReactiveResumeV4JSONImporter {
|
||||
id: item.id ?? generateId(),
|
||||
hidden: !(item.visible ?? true),
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: item.name!,
|
||||
keywords: item.keywords ?? [],
|
||||
})),
|
||||
|
||||
@@ -121,7 +121,7 @@ export function SectionDropdownMenu({ type }: Props) {
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent align="end">
|
||||
{type !== "summary" && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
|
||||
@@ -14,7 +14,7 @@ function handler() {
|
||||
"Surrogate-Control": "max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Robots-Tag": "index, follow",
|
||||
ETag: `"v5.0.0"`,
|
||||
ETag: __APP_VERSION__,
|
||||
Vary: "Accept",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,13 @@ export const iconSchema = z
|
||||
"The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available.",
|
||||
);
|
||||
|
||||
export const iconColorSchema = z
|
||||
.string()
|
||||
.catch("")
|
||||
.describe(
|
||||
"Optional custom color for the icon, defined as rgba(r, g, b, a). Leave blank to use the template default icon color.",
|
||||
);
|
||||
|
||||
export const itemOptionsSchema = z
|
||||
.object({
|
||||
showLinkInTitle: z
|
||||
@@ -164,6 +171,7 @@ export const experienceItemSchema = baseItemSchema.extend({
|
||||
|
||||
export const interestItemSchema = baseItemSchema.extend({
|
||||
icon: iconSchema,
|
||||
iconColor: iconColorSchema,
|
||||
name: z.string().min(1).describe("The name of the interest/hobby."),
|
||||
keywords: z
|
||||
.array(z.string())
|
||||
@@ -190,6 +198,7 @@ export const languageItemSchema = baseItemSchema.extend({
|
||||
|
||||
export const profileItemSchema = baseItemSchema.extend({
|
||||
icon: iconSchema,
|
||||
iconColor: iconColorSchema,
|
||||
network: z.string().min(1).describe("The name of the network or platform."),
|
||||
username: z.string().describe("The username of the author on the network or platform."),
|
||||
website: urlSchema.describe("The link to the profile of the author on the network or platform, if any."),
|
||||
@@ -224,6 +233,7 @@ export const referenceItemSchema = baseItemSchema.extend({
|
||||
|
||||
export const skillItemSchema = baseItemSchema.extend({
|
||||
icon: iconSchema,
|
||||
iconColor: iconColorSchema,
|
||||
name: z.string().min(1).describe("The name of the skill."),
|
||||
proficiency: z
|
||||
.string()
|
||||
|
||||
@@ -55,6 +55,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-3d42ddc9b4d8",
|
||||
hidden: false,
|
||||
icon: "github-logo",
|
||||
iconColor: "",
|
||||
network: "GitHub",
|
||||
username: "dkowalski-dev",
|
||||
website: {
|
||||
@@ -66,6 +67,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-43c470b77f4a",
|
||||
hidden: false,
|
||||
icon: "linkedin-logo",
|
||||
iconColor: "",
|
||||
network: "LinkedIn",
|
||||
username: "davidkowalski",
|
||||
website: {
|
||||
@@ -173,6 +175,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-5a52dcf50ed4",
|
||||
hidden: false,
|
||||
icon: "code",
|
||||
iconColor: "",
|
||||
name: "Unity Engine",
|
||||
proficiency: "Expert",
|
||||
level: 5,
|
||||
@@ -182,6 +185,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-5e8bb7cacbc8",
|
||||
hidden: false,
|
||||
icon: "brackets-curly",
|
||||
iconColor: "",
|
||||
name: "Unreal Engine",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
@@ -191,6 +195,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-622f9d41da55",
|
||||
hidden: false,
|
||||
icon: "cpu",
|
||||
iconColor: "",
|
||||
name: "Programming Languages",
|
||||
proficiency: "Expert",
|
||||
level: 5,
|
||||
@@ -200,6 +205,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-6574ab6814bd",
|
||||
hidden: false,
|
||||
icon: "brain",
|
||||
iconColor: "",
|
||||
name: "Game AI",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
@@ -209,6 +215,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-6a8e22bec684",
|
||||
hidden: false,
|
||||
icon: "shooting-star",
|
||||
iconColor: "",
|
||||
name: "Physics & Mathematics",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
@@ -218,6 +225,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-6d8bf7be7514",
|
||||
hidden: true,
|
||||
icon: "chart-line-up",
|
||||
iconColor: "",
|
||||
name: "Performance Optimization",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
@@ -255,6 +263,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-7821b4de95f7",
|
||||
hidden: false,
|
||||
icon: "game-controller",
|
||||
iconColor: "",
|
||||
name: "Game Design",
|
||||
keywords: ["Mechanics", "Level Design", "Player Psychology"],
|
||||
},
|
||||
@@ -262,6 +271,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-7c64c1a607d3",
|
||||
hidden: false,
|
||||
icon: "robot",
|
||||
iconColor: "",
|
||||
name: "AI & Procedural Generation",
|
||||
keywords: ["PCG", "Machine Learning", "Emergent Gameplay"],
|
||||
},
|
||||
@@ -269,6 +279,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-80bccce3c0ef",
|
||||
hidden: false,
|
||||
icon: "book-open",
|
||||
iconColor: "",
|
||||
name: "Indie Game Development",
|
||||
keywords: ["Solo Dev", "Game Jams", "Community"],
|
||||
},
|
||||
@@ -276,6 +287,7 @@ export const sampleResumeData: ResumeData = {
|
||||
id: "019bef5a-93e4-7746-ad39-84bb7e9af005",
|
||||
hidden: false,
|
||||
icon: "pen-nib",
|
||||
iconColor: "",
|
||||
name: "Technical Art",
|
||||
keywords: ["Shaders", "VFX", "Optimization"],
|
||||
},
|
||||
|
||||
+646
-165
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,7 @@ describe("buildDocument", () => {
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
|
||||
@@ -198,6 +198,7 @@ describe("renderBuiltInSection — skills", () => {
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
@@ -213,7 +214,16 @@ describe("renderBuiltInSection — skills", () => {
|
||||
const sections = freshSections();
|
||||
sections.skills.title = "Skills";
|
||||
sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] },
|
||||
{
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
|
||||
@@ -315,6 +325,7 @@ describe("renderBuiltInSection — other sections", () => {
|
||||
id: "p1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
network: "GitHub",
|
||||
username: "janedoe",
|
||||
website: { url: "https://github.com/janedoe", label: "GitHub" },
|
||||
@@ -423,7 +434,16 @@ describe("renderBuiltInSection — other sections", () => {
|
||||
it("renders interests with keywords", () => {
|
||||
const sections = freshSections();
|
||||
sections.interests.title = "Interests";
|
||||
sections.interests.items = [{ id: "i1", hidden: false, icon: "", name: "Gaming", keywords: ["RPG", "Strategy"] }];
|
||||
sections.interests.items = [
|
||||
{
|
||||
id: "i1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Gaming",
|
||||
keywords: ["RPG", "Strategy"],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("interests", sections.interests, COLOR_HEX);
|
||||
expect(result.length).toBe(2);
|
||||
@@ -486,7 +506,18 @@ describe("renderCustomSection", () => {
|
||||
title: "Hidden",
|
||||
columns: 1,
|
||||
hidden: true,
|
||||
items: [{ id: "ci1", hidden: false, icon: "", name: "Skill", proficiency: "", level: 0, keywords: [] }],
|
||||
items: [
|
||||
{
|
||||
id: "ci1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Skill",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(renderCustomSection(custom, COLOR_HEX)).toHaveLength(0);
|
||||
@@ -499,7 +530,18 @@ describe("renderCustomSection", () => {
|
||||
title: "Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "ci1", hidden: true, icon: "", name: "Skill", proficiency: "", level: 0, keywords: [] }],
|
||||
items: [
|
||||
{
|
||||
id: "ci1",
|
||||
hidden: true,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Skill",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(renderCustomSection(custom, COLOR_HEX)).toHaveLength(0);
|
||||
@@ -548,7 +590,16 @@ describe("renderCustomSection", () => {
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{ id: "ci1", hidden: false, icon: "", name: "Leadership", proficiency: "Expert", level: 5, keywords: [] },
|
||||
{
|
||||
id: "ci1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Leadership",
|
||||
proficiency: "Expert",
|
||||
level: 5,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -575,7 +626,16 @@ describe("setRenderConfig", () => {
|
||||
const sections = freshSections();
|
||||
sections.skills.title = "Skills";
|
||||
sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "Test", proficiency: "", level: 0, keywords: [] },
|
||||
{
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Test",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("skills", sections.skills, "#0000FF");
|
||||
|
||||
@@ -38,9 +38,36 @@ function freshResume(): ResumeData {
|
||||
function resumeWithSkills(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "Advanced", level: 4, keywords: ["frontend"] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "Intermediate", level: 3, keywords: [] },
|
||||
{ id: "s3", hidden: false, icon: "", name: "Rust", proficiency: "Beginner", level: 1, keywords: [] },
|
||||
{
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "JS",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["frontend"],
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "TS",
|
||||
proficiency: "Intermediate",
|
||||
level: 3,
|
||||
keywords: [],
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Rust",
|
||||
proficiency: "Beginner",
|
||||
level: 1,
|
||||
keywords: [],
|
||||
},
|
||||
];
|
||||
return data;
|
||||
}
|
||||
@@ -74,7 +101,18 @@ function resumeWithCustomSections(): ResumeData {
|
||||
title: "Soft Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "cs1", hidden: false, icon: "", name: "Leadership", proficiency: "", level: 0, keywords: [] }],
|
||||
items: [
|
||||
{
|
||||
id: "cs1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Leadership",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// Add custom sections to the layout
|
||||
@@ -306,7 +344,16 @@ describe("removeItemFromSource", () => {
|
||||
describe("addItemToSection", () => {
|
||||
it("adds an item to a standard section", () => {
|
||||
const data = freshResume();
|
||||
const newItem = { id: "new-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const newItem = {
|
||||
id: "new-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "skills", "skills");
|
||||
@@ -318,7 +365,16 @@ describe("addItemToSection", () => {
|
||||
|
||||
it("adds an item to a custom section", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const newItem = { id: "new-2", hidden: false, icon: "", name: "Teamwork", proficiency: "", level: 0, keywords: [] };
|
||||
const newItem = {
|
||||
id: "new-2",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Teamwork",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "custom-skills-1", "skills");
|
||||
@@ -330,7 +386,16 @@ describe("addItemToSection", () => {
|
||||
|
||||
it("does nothing when target custom section doesn't exist", () => {
|
||||
const data = freshResume();
|
||||
const newItem = { id: "new-3", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const newItem = {
|
||||
id: "new-3",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "nonexistent-section", "skills");
|
||||
@@ -342,7 +407,16 @@ describe("addItemToSection", () => {
|
||||
|
||||
it("appends to the end of an existing items array", () => {
|
||||
const data = resumeWithSkills();
|
||||
const newItem = { id: "s4", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const newItem = {
|
||||
id: "s4",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "skills", "skills");
|
||||
@@ -360,7 +434,16 @@ describe("addItemToSection", () => {
|
||||
describe("createCustomSectionWithItem", () => {
|
||||
it("creates a new custom section with the given item", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const item = {
|
||||
id: "item-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
const sectionId = createCustomSectionWithItem(draft, item, "skills", "Tech Skills", 0);
|
||||
@@ -377,7 +460,16 @@ describe("createCustomSectionWithItem", () => {
|
||||
|
||||
it("adds the new section to the target page's main column", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const item = {
|
||||
id: "item-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
const mainLengthBefore = data.metadata.layout.pages[0].main.length;
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
@@ -390,7 +482,16 @@ describe("createCustomSectionWithItem", () => {
|
||||
|
||||
it("handles targeting a specific page index", () => {
|
||||
const data = resumeWithMultiplePages();
|
||||
const item = { id: "item-2", hidden: false, icon: "", name: "Python", proficiency: "", level: 0, keywords: [] };
|
||||
const item = {
|
||||
id: "item-2",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Python",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createCustomSectionWithItem(draft, item, "skills", "More Skills", 1);
|
||||
@@ -403,7 +504,16 @@ describe("createCustomSectionWithItem", () => {
|
||||
|
||||
it("handles out-of-bounds page index gracefully (no crash)", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-3", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const item = {
|
||||
id: "item-3",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
// Should not throw — the page just doesn't exist so nothing is pushed
|
||||
const result = produce(data, (draft) => {
|
||||
@@ -424,7 +534,16 @@ describe("createCustomSectionWithItem", () => {
|
||||
describe("createPageWithSection", () => {
|
||||
it("creates a new page with a custom section", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const item = {
|
||||
id: "item-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
const pagesBefore = data.metadata.layout.pages.length;
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
@@ -440,7 +559,16 @@ describe("createPageWithSection", () => {
|
||||
|
||||
it("creates the custom section with correct properties", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const item = {
|
||||
id: "item-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createPageWithSection(draft, item, "skills", "Page 2 Skills");
|
||||
@@ -454,8 +582,26 @@ describe("createPageWithSection", () => {
|
||||
|
||||
it("can create multiple pages in sequence", () => {
|
||||
const data = freshResume();
|
||||
const item1 = { id: "i1", hidden: false, icon: "", name: "A", proficiency: "", level: 0, keywords: [] };
|
||||
const item2 = { id: "i2", hidden: false, icon: "", name: "B", proficiency: "", level: 0, keywords: [] };
|
||||
const item1 = {
|
||||
id: "i1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "A",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
const item2 = {
|
||||
id: "i2",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "B",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createPageWithSection(draft, item1, "skills", "Page 2");
|
||||
|
||||
@@ -147,8 +147,8 @@ describe("applyResumePatches", () => {
|
||||
it("removes an item from a section's items array", () => {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s1", hidden: false, icon: "", iconColor: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s2", hidden: false, icon: "", iconColor: "", name: "TS", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "remove", path: "/sections/skills/items/0" }]);
|
||||
@@ -455,8 +455,8 @@ describe("applyResumePatches", () => {
|
||||
it("replaces a specific item in an array", () => {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s1", hidden: false, icon: "", iconColor: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s2", hidden: false, icon: "", iconColor: "", name: "TS", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = applyResumePatches(data, [
|
||||
|
||||
@@ -53,7 +53,18 @@ function resumeWithCustomSection(): ResumeData {
|
||||
title: "Soft Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "cs-1", hidden: false, icon: "", name: "Leadership", proficiency: "", level: 0, keywords: [] }],
|
||||
items: [
|
||||
{
|
||||
id: "cs-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Leadership",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return data;
|
||||
|
||||
@@ -67,6 +67,7 @@ function makeResumeData(overrides?: {
|
||||
id: "skill-0",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "JavaScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
@@ -76,6 +77,7 @@ function makeResumeData(overrides?: {
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Python",
|
||||
proficiency: "Intermediate",
|
||||
level: 3,
|
||||
@@ -85,6 +87,7 @@ function makeResumeData(overrides?: {
|
||||
id: "skill-2",
|
||||
hidden: true,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Photography",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
|
||||
Reference in New Issue
Block a user