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
This commit is contained in:
Amruth Pillai
2026-01-31 01:53:27 +01:00
committed by GitHub
parent 3d1c2d1fb6
commit aa12fcbd36
63 changed files with 1090 additions and 26 deletions
@@ -1,5 +1,11 @@
import { match } from "ts-pattern";
import type { CustomSectionItem, SectionItem, SectionType } from "@/schema/resume/data";
import type {
CustomSectionItem,
CustomSectionType,
SectionItem,
SectionType,
SummaryItem as SummaryItemType,
} from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
import { AwardsItem } from "./items/awards-item";
@@ -13,6 +19,7 @@ import { ProjectsItem } from "./items/projects-item";
import { PublicationsItem } from "./items/publications-item";
import { ReferencesItem } from "./items/references-item";
import { SkillsItem } from "./items/skills-item";
import { SummaryItem } from "./items/summary-item";
import { VolunteerItem } from "./items/volunteer-item";
import { PageSection } from "./page-section";
import { PageSummary } from "./page-summary";
@@ -23,8 +30,9 @@ type SectionComponentProps = {
};
// Helper to render item component based on type
function renderItemByType(type: SectionType, item: CustomSectionItem, itemClassName?: string) {
function renderItemByType(type: CustomSectionType, item: CustomSectionItem, itemClassName?: string) {
return match(type)
.with("summary", () => <SummaryItem {...(item as SummaryItemType)} className={itemClassName} />)
.with("profiles", () => <ProfilesItem {...(item as SectionItem<"profiles">)} className={itemClassName} />)
.with("experience", () => <ExperienceItem {...(item as SectionItem<"experience">)} className={itemClassName} />)
.with("education", () => <EducationItem {...(item as SectionItem<"education">)} className={itemClassName} />)
@@ -163,7 +171,9 @@ export function getSectionComponent(
return (
<section className={cn(`page-section page-section-custom page-section-${id}`, sectionClassName)}>
<h6 className="mb-1.5 text-(--page-primary-color)">{customSection.title}</h6>
{customSection.type !== "summary" && (
<h6 className="mb-1.5 text-(--page-primary-color)">{customSection.title}</h6>
)}
<div
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
@@ -0,0 +1,18 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SummaryItem as SummaryItemType } from "@/schema/resume/data";
import { stripHtml } from "@/utils/string";
import { cn } from "@/utils/style";
type SummaryItemProps = SummaryItemType & {
className?: string;
};
export function SummaryItem({ className, ...item }: SummaryItemProps) {
if (!stripHtml(item.content)) return null;
return (
<div className={cn("summary-item", className)}>
<TiptapContent content={item.content} />
</div>
);
}
+3
View File
@@ -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 }) => <UpdateVolunteerDialog data={data} />)
.with({ type: "resume.sections.references.create" }, ({ data }) => <CreateReferenceDialog data={data} />)
.with({ type: "resume.sections.references.update" }, ({ data }) => <UpdateReferenceDialog data={data} />)
.with({ type: "resume.sections.summary.create" }, ({ data }) => <CreateSummaryItemDialog data={data} />)
.with({ type: "resume.sections.summary.update" }, ({ data }) => <UpdateSummaryItemDialog data={data} />)
.with({ type: "resume.sections.custom.create" }, ({ data }) => <CreateCustomSectionDialog data={data} />)
.with({ type: "resume.sections.custom.update" }, ({ data }) => <UpdateCustomSectionDialog data={data} />)
.otherwise(() => null);
+3 -2
View File
@@ -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<typeof formSchema>;
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" },
@@ -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<typeof formSchema>;
export function CreateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
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 (
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new summary item</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<SummaryItemForm />
<DialogFooter>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeStore = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
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 (
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing summary item</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<SummaryItemForm />
<DialogFooter>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function SummaryItemForm() {
const form = useFormContext<FormValues>();
return (
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
+9
View File
@@ -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 }),
]);
@@ -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))
@@ -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<T extends SectionItemType> = {
type: SectionType;
type Props<T extends CustomSectionItem | SectionItemType> = {
type: CustomSectionType;
item: T;
title: string;
subtitle?: string;
customSectionId?: string;
};
export function SectionItem<T extends SectionItemType>({ type, item, title, subtitle, customSectionId }: Props<T>) {
export function SectionItem<T extends CustomSectionItem | SectionItemType>({
type,
item,
title,
subtitle,
customSectionId,
}: Props<T>) {
const confirm = useConfirm();
const controls = useDragControls();
const { openDialog } = useDialogStore();
@@ -176,7 +187,8 @@ export function SectionItem<T extends SectionItemType>({ 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<T extends SectionItemType>({ 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<T extends SectionItemType>({ type, item, title, subt
}
type AddButtonProps = Omit<ButtonProps, "type"> & {
type: SectionType | "custom";
type: CustomSectionType | "custom";
customSectionId?: string;
};
+10
View File
@@ -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<typeof summaryItemSchema>;
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<T extends SectionType = SectionType> = z.infer<typeof se
export type SectionItem<T extends SectionType = SectionType> = SectionData<T>["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<typeof sectionTypeSchema>;
export const customSectionItemSchema = z.union([
summaryItemSchema,
profileItemSchema,
experienceItemSchema,
educationItemSchema,
+14 -9
View File
@@ -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<ResumeData>,
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<ResumeData>,
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<ResumeData>,
item: SectionItem,
type: SectionType,
type: CustomSectionType,
sectionTitle: string,
targetPageIndex: number,
): string {
@@ -243,7 +248,7 @@ export function createCustomSectionWithItem(
export function createPageWithSection(
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: SectionType,
type: CustomSectionType,
sectionTitle: string,
): void {
const newSectionId = generateId();