From aa12fcbd36cff1ed6608777f727ea2cf2375bb8e Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 31 Jan 2026 01:53:27 +0100 Subject: [PATCH] Feature: Implement a new custom section type: `summary` (#2657) * feat: add summaryItemSchema for custom summary section type * feat: add CreateSummaryItemDialog and UpdateSummaryItemDialog * feat: register summary item dialog types in store * feat: route summary item dialogs in manager * feat: add SummaryItem render component * feat: handle summary type in renderItemByType and hide title for summary sections * feat: handle summary type in sidebar helpers * feat: add summary to custom section type options * fix: update type definitions to support CustomSectionType for summary sections * chore: extract new i18n strings for summary section * style: apply biome formatting fixes * chore: remove TODO.md file containing outdated feature specifications --- locales/af-ZA.po | Bin 96429 -> 96888 bytes locales/am-ET.po | 16 ++ locales/ar-SA.po | 16 ++ locales/az-AZ.po | 16 ++ locales/bg-BG.po | 16 ++ locales/bn-BD.po | 16 ++ locales/ca-ES.po | 16 ++ locales/cs-CZ.po | 16 ++ locales/da-DK.po | 16 ++ locales/de-DE.po | 16 ++ locales/el-GR.po | 16 ++ locales/en-US.po | 16 ++ locales/es-ES.po | 16 ++ locales/fa-IR.po | 16 ++ locales/fi-FI.po | 16 ++ locales/fr-FR.po | 16 ++ locales/he-IL.po | 16 ++ locales/hi-IN.po | 16 ++ locales/hu-HU.po | 16 ++ locales/id-ID.po | 16 ++ locales/it-IT.po | 16 ++ locales/ja-JP.po | 16 ++ locales/km-KH.po | 16 ++ locales/kn-IN.po | 16 ++ locales/ko-KR.po | 16 ++ locales/lt-LT.po | 16 ++ locales/lv-LV.po | 16 ++ locales/ml-IN.po | 16 ++ locales/mr-IN.po | 16 ++ locales/ms-MY.po | 16 ++ locales/ne-NP.po | 16 ++ locales/nl-NL.po | 16 ++ locales/no-NO.po | 16 ++ locales/or-IN.po | 16 ++ locales/pl-PL.po | 16 ++ locales/pt-BR.po | 16 ++ locales/pt-PT.po | 16 ++ locales/ro-RO.po | 16 ++ locales/ru-RU.po | 16 ++ locales/sk-SK.po | 16 ++ locales/sq-AL.po | 16 ++ locales/sr-SP.po | 16 ++ locales/sv-SE.po | 16 ++ locales/ta-IN.po | 16 ++ locales/te-IN.po | 16 ++ locales/th-TH.po | 16 ++ locales/tr-TR.po | 16 ++ locales/uk-UA.po | 16 ++ locales/uz-UZ.po | 16 ++ locales/vi-VN.po | 16 ++ locales/zh-CN.po | 16 ++ locales/zh-TW.po | 16 ++ locales/zu-ZA.po | 16 ++ .../resume/shared/get-section-component.tsx | 16 +- .../resume/shared/items/summary-item.tsx | 18 +++ src/dialogs/manager.tsx | 3 + src/dialogs/resume/sections/custom.tsx | 5 +- src/dialogs/resume/sections/summary-item.tsx | 151 ++++++++++++++++++ src/dialogs/store.ts | 9 ++ .../-sidebar/left/sections/custom.tsx | 18 ++- .../-sidebar/left/shared/section-item.tsx | 31 ++-- src/schema/resume/data.ts | 10 ++ src/utils/resume/move-item.ts | 23 +-- 63 files changed, 1090 insertions(+), 26 deletions(-) create mode 100644 src/components/resume/shared/items/summary-item.tsx create mode 100644 src/dialogs/resume/sections/summary-item.tsx diff --git a/locales/af-ZA.po b/locales/af-ZA.po index b3b662bb18272dd870d8a8fe6b570c4d7980b633..f0a58e371487f8b463f825d65dbdc44e5ce4b90c 100644 GIT binary patch delta 265 zcmZ4ck@d$H)(y6fJjJECxrs%Ux|t=Zxsw&MRWJlL+d59+o@}sAP!36@UP*BUS8j27 zW{QH6bADb)YF^3Y!s#ND<+gE5PY7dFnk?(9wOQ6Tg%iWL$$w*|H_r)6WJi^`A0^2R zb&3MeDU%DPivkUk*!(w^Z8oY(pc^OKI*LxtKg2%$gDa!(FZ5-U V$7UkXe$nZ*zKp`#>--t@H38Z%WbOa} delta 86 zcmezIg>~&m)(y6flPBczZC>rz#y$ChuhQmuzI~iP5z)!(!`U{MhbOW_IlP<0 ) .with("profiles", () => )} className={itemClassName} />) .with("experience", () => )} className={itemClassName} />) .with("education", () => )} className={itemClassName} />) @@ -163,7 +171,9 @@ export function getSectionComponent( return (
-
{customSection.title}
+ {customSection.type !== "summary" && ( +
{customSection.title}
+ )}
+ +
+ ); +} diff --git a/src/dialogs/manager.tsx b/src/dialogs/manager.tsx index a5dec6a41..46bb031c9 100644 --- a/src/dialogs/manager.tsx +++ b/src/dialogs/manager.tsx @@ -18,6 +18,7 @@ import { CreateProjectDialog, UpdateProjectDialog } from "./resume/sections/proj import { CreatePublicationDialog, UpdatePublicationDialog } from "./resume/sections/publication"; import { CreateReferenceDialog, UpdateReferenceDialog } from "./resume/sections/reference"; import { CreateSkillDialog, UpdateSkillDialog } from "./resume/sections/skill"; +import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./resume/sections/summary-item"; import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./resume/sections/volunteer"; import { TemplateGalleryDialog } from "./resume/template/gallery"; import { useDialogStore } from "./store"; @@ -59,6 +60,8 @@ export function DialogManager() { .with({ type: "resume.sections.volunteer.update" }, ({ data }) => ) .with({ type: "resume.sections.references.create" }, ({ data }) => ) .with({ type: "resume.sections.references.update" }, ({ data }) => ) + .with({ type: "resume.sections.summary.create" }, ({ data }) => ) + .with({ type: "resume.sections.summary.update" }, ({ data }) => ) .with({ type: "resume.sections.custom.create" }, ({ data }) => ) .with({ type: "resume.sections.custom.update" }, ({ data }) => ) .otherwise(() => null); diff --git a/src/dialogs/resume/sections/custom.tsx b/src/dialogs/resume/sections/custom.tsx index 4c8cbc06f..62323455e 100644 --- a/src/dialogs/resume/sections/custom.tsx +++ b/src/dialogs/resume/sections/custom.tsx @@ -11,14 +11,15 @@ import { Input } from "@/components/ui/input"; import type { DialogProps } from "@/dialogs/store"; import { useDialogStore } from "@/dialogs/store"; import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { customSectionSchema, type SectionType } from "@/schema/resume/data"; +import { type CustomSectionType, customSectionSchema } from "@/schema/resume/data"; import { generateId } from "@/utils/string"; const formSchema = customSectionSchema; type FormValues = z.infer; -const SECTION_TYPE_OPTIONS: { value: SectionType; label: string }[] = [ +const SECTION_TYPE_OPTIONS: { value: CustomSectionType; label: string }[] = [ + { value: "summary", label: "Summary" }, { value: "experience", label: "Experience" }, { value: "education", label: "Education" }, { value: "projects", label: "Projects" }, diff --git a/src/dialogs/resume/sections/summary-item.tsx b/src/dialogs/resume/sections/summary-item.tsx new file mode 100644 index 000000000..3d8a993a6 --- /dev/null +++ b/src/dialogs/resume/sections/summary-item.tsx @@ -0,0 +1,151 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Trans } from "@lingui/react/macro"; +import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; +import { useForm, useFormContext } from "react-hook-form"; +import type z from "zod"; +import { RichInput } from "@/components/input/rich-input"; +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 type { DialogProps } from "@/dialogs/store"; +import { useDialogStore } from "@/dialogs/store"; +import { useFormBlocker } from "@/hooks/use-form-blocker"; +import { summaryItemSchema } from "@/schema/resume/data"; +import { generateId } from "@/utils/string"; + +const formSchema = summaryItemSchema; + +type FormValues = z.infer; + +export function CreateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.create">) { + const closeDialog = useDialogStore((state) => state.closeDialog); + const updateResumeData = useResumeStore((state) => state.updateResumeData); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + id: generateId(), + hidden: data?.item?.hidden ?? false, + content: data?.item?.content ?? "", + }, + }); + + const onSubmit = (formData: FormValues) => { + updateResumeData((draft) => { + if (data?.customSectionId) { + const section = draft.customSections.find((s) => s.id === data.customSectionId); + if (section) section.items.push(formData); + } + }); + closeDialog(); + }; + + const { blockEvents, requestClose } = useFormBlocker(form); + + return ( + + + + + Create a new summary item + + + + +
+ + + + + + + + + + +
+ ); +} + +export function UpdateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.update">) { + const closeDialog = useDialogStore((state) => state.closeDialog); + const updateResumeStore = useResumeStore((state) => state.updateResumeData); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + id: data.item.id, + hidden: data.item.hidden, + content: data.item.content, + }, + }); + + const onSubmit = (formData: FormValues) => { + updateResumeStore((draft) => { + if (data?.customSectionId) { + const section = draft.customSections.find((s) => s.id === data.customSectionId); + if (!section) return; + const index = section.items.findIndex((item) => item.id === formData.id); + if (index !== -1) section.items[index] = formData; + } + }); + closeDialog(); + }; + + const { blockEvents, requestClose } = useFormBlocker(form); + + return ( + + + + + Update an existing summary item + + + + +
+ + + + + + + + + + +
+ ); +} + +function SummaryItemForm() { + const form = useFormContext(); + + return ( + ( + + + Content + + + + + + + )} + /> + ); +} diff --git a/src/dialogs/store.ts b/src/dialogs/store.ts index 5b928d232..a5b4c0244 100644 --- a/src/dialogs/store.ts +++ b/src/dialogs/store.ts @@ -13,6 +13,7 @@ import { publicationItemSchema, referenceItemSchema, skillItemSchema, + summaryItemSchema, volunteerItemSchema, } from "@/schema/resume/data"; @@ -134,6 +135,14 @@ const dialogTypeSchema = z.discriminatedUnion("type", [ type: z.literal("resume.sections.references.update"), data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }), }), + z.object({ + type: z.literal("resume.sections.summary.create"), + data: z.object({ item: summaryItemSchema.optional(), customSectionId: z.string().optional() }).optional(), + }), + z.object({ + type: z.literal("resume.sections.summary.update"), + data: z.object({ item: summaryItemSchema, customSectionId: z.string().optional() }), + }), z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }), z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }), ]); diff --git a/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx b/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx index 615e0b7de..77f5e47bb 100644 --- a/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx +++ b/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx @@ -27,14 +27,25 @@ import { } from "@/components/ui/dropdown-menu"; import { useDialogStore } from "@/dialogs/store"; import { useConfirm } from "@/hooks/use-confirm"; -import type { CustomSection, CustomSectionItem as CustomSectionItemType, SectionType } from "@/schema/resume/data"; +import type { + CustomSection, + CustomSectionItem as CustomSectionItemType, + CustomSectionType, +} from "@/schema/resume/data"; import { getSectionTitle } from "@/utils/resume/section"; import { cn } from "@/utils/style"; import { SectionBase } from "../shared/section-base"; import { SectionAddItemButton, SectionItem } from "../shared/section-item"; -function getItemTitle(type: SectionType, item: CustomSectionItemType): string { +function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): string { return match(type) + .with("summary", () => { + if ("content" in item) { + const stripped = item.content.replace(/<[^>]*>/g, "").trim(); + return stripped.length > 50 ? `${stripped.slice(0, 50)}...` : stripped || "Summary"; + } + return "Summary"; + }) .with("profiles", () => ("network" in item ? item.network : "")) .with("experience", () => ("company" in item ? item.company : "")) .with("education", () => ("school" in item ? item.school : "")) @@ -50,8 +61,9 @@ function getItemTitle(type: SectionType, item: CustomSectionItemType): string { .exhaustive(); } -function getItemSubtitle(type: SectionType, item: CustomSectionItemType): string | undefined { +function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType): string | undefined { return match(type) + .with("summary", () => undefined) .with("profiles", () => ("username" in item ? item.username : undefined)) .with("experience", () => ("position" in item ? item.position : undefined)) .with("education", () => ("degree" in item ? item.degree : undefined)) diff --git a/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx b/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx index fc97c2812..137a97392 100644 --- a/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx +++ b/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx @@ -30,7 +30,12 @@ import { } from "@/components/ui/dropdown-menu"; import { useDialogStore } from "@/dialogs/store"; import { useConfirm } from "@/hooks/use-confirm"; -import type { SectionItem as SectionItemType, SectionType } from "@/schema/resume/data"; +import type { + CustomSectionItem, + CustomSectionType, + SectionItem as SectionItemType, + SectionType, +} from "@/schema/resume/data"; import { addItemToSection, createCustomSectionWithItem, @@ -46,8 +51,8 @@ import { cn } from "@/utils/style"; // ============================================================================ type MoveItemSubmenuProps = { - type: SectionType; - item: SectionItemType; + type: CustomSectionType; + item: CustomSectionItem | SectionItemType; customSectionId?: string; }; @@ -153,15 +158,21 @@ function MoveItemSubmenu({ type, item, customSectionId }: MoveItemSubmenuProps) // SectionItem Component // ============================================================================ -type Props = { - type: SectionType; +type Props = { + type: CustomSectionType; item: T; title: string; subtitle?: string; customSectionId?: string; }; -export function SectionItem({ type, item, title, subtitle, customSectionId }: Props) { +export function SectionItem({ + type, + item, + title, + subtitle, + customSectionId, +}: Props) { const confirm = useConfirm(); const controls = useDragControls(); const { openDialog } = useDialogStore(); @@ -176,7 +187,8 @@ export function SectionItem({ type, item, title, subt if (index === -1) return; section.items[index].hidden = !section.items[index].hidden; } else { - const section = draft.sections[type]; + // Type assertion: when customSectionId is not provided, type is always a built-in SectionType + const section = draft.sections[type as SectionType]; if (!("items" in section)) return; const index = section.items.findIndex((_item) => _item.id === item.id); if (index === -1) return; @@ -211,7 +223,8 @@ export function SectionItem({ type, item, title, subt if (index === -1) return; section.items.splice(index, 1); } else { - const section = draft.sections[type]; + // Type assertion: when customSectionId is not provided, type is always a built-in SectionType + const section = draft.sections[type as SectionType]; if (!("items" in section)) return; const index = section.items.findIndex((_item) => _item.id === item.id); if (index === -1) return; @@ -298,7 +311,7 @@ export function SectionItem({ type, item, title, subt } type AddButtonProps = Omit & { - type: SectionType | "custom"; + type: CustomSectionType | "custom"; customSectionId?: string; }; diff --git a/src/schema/resume/data.ts b/src/schema/resume/data.ts index 6b6bfc199..273e274f0 100644 --- a/src/schema/resume/data.ts +++ b/src/schema/resume/data.ts @@ -86,6 +86,12 @@ export const baseItemSchema = z.object({ hidden: z.boolean().describe("Whether to hide the item from the resume."), }); +export const summaryItemSchema = baseItemSchema.extend({ + content: z.string().describe("The rich text content of the summary item. This should be a HTML-formatted string."), +}); + +export type SummaryItem = z.infer; + export const awardItemSchema = baseItemSchema.extend({ title: z.string().min(1).describe("The title of the award."), awarder: z.string().describe("The awarder of the award."), @@ -288,6 +294,7 @@ export type SectionData = z.infer = SectionData["items"][number]; export const sectionTypeSchema = z.enum([ + "summary", "profiles", "experience", "education", @@ -302,7 +309,10 @@ export const sectionTypeSchema = z.enum([ "references", ]); +export type CustomSectionType = z.infer; + export const customSectionItemSchema = z.union([ + summaryItemSchema, profileItemSchema, experienceItemSchema, educationItemSchema, diff --git a/src/utils/resume/move-item.ts b/src/utils/resume/move-item.ts index 162c55c29..c90c4f743 100644 --- a/src/utils/resume/move-item.ts +++ b/src/utils/resume/move-item.ts @@ -1,5 +1,5 @@ import type { WritableDraft } from "immer"; -import type { CustomSection, ResumeData, SectionItem, SectionType } from "@/schema/resume/data"; +import type { CustomSection, CustomSectionType, ResumeData, SectionItem, SectionType } from "@/schema/resume/data"; import { generateId } from "@/utils/string"; import { getSectionTitle as getDefaultSectionTitle } from "./section"; @@ -62,7 +62,11 @@ function isStandardSectionId(sectionId: string): sectionId is SectionType { * @param customSectionId - The custom section ID (if applicable) * @returns The section title */ -export function getSourceSectionTitle(resumeData: ResumeData, type: SectionType, customSectionId?: string): string { +export function getSourceSectionTitle( + resumeData: ResumeData, + type: CustomSectionType, + customSectionId?: string, +): string { if (customSectionId) { const customSection = resumeData.customSections.find((s) => s.id === customSectionId); return customSection?.title ?? getDefaultSectionTitle(type); @@ -82,7 +86,7 @@ export function getSourceSectionTitle(resumeData: ResumeData, type: SectionType, */ export function getCompatibleMoveTargets( resumeData: ResumeData, - sourceType: SectionType, + sourceType: CustomSectionType, sourceSectionId: string | undefined, ): MoveTargetPage[] { const { pages } = resumeData.metadata.layout; @@ -138,7 +142,7 @@ export function getCompatibleMoveTargets( export function removeItemFromSource( draft: WritableDraft, itemId: string, - type: SectionType, + type: CustomSectionType, customSectionId?: string, ): SectionItem | null { if (customSectionId) { @@ -152,7 +156,8 @@ export function removeItemFromSource( return removed as SectionItem; } - const section = draft.sections[type]; + // Type assertion: when customSectionId is not provided, type is always a built-in SectionType + const section = draft.sections[type as SectionType]; if (!("items" in section)) return null; const index = section.items.findIndex((item) => item.id === itemId); @@ -174,11 +179,11 @@ export function addItemToSection( draft: WritableDraft, item: SectionItem, targetSectionId: string, - type: SectionType, + type: CustomSectionType, ): void { // Check if target is a standard section if (isStandardSectionId(targetSectionId) && targetSectionId === type) { - const section = draft.sections[type]; + const section = draft.sections[type as SectionType]; if ("items" in section) { section.items.push(item as never); } @@ -205,7 +210,7 @@ export function addItemToSection( export function createCustomSectionWithItem( draft: WritableDraft, item: SectionItem, - type: SectionType, + type: CustomSectionType, sectionTitle: string, targetPageIndex: number, ): string { @@ -243,7 +248,7 @@ export function createCustomSectionWithItem( export function createPageWithSection( draft: WritableDraft, item: SectionItem, - type: SectionType, + type: CustomSectionType, sectionTitle: string, ): void { const newSectionId = generateId();