From a82741f42a09093250e3fdf514fd68ec745fbc66 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 18:20:26 +0200 Subject: [PATCH] feat(web): edit resumes inline in the Write panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write becomes the Basics card, the outline of sections in print order and Add section. Entries open in place (one at a time, the editor selection), save as you type and show drafts as "Draft · not printed". Dates use a structured field with a Present switch and the review note for text that wasn't read exactly; descriptions use a restricted rich-text toolbar shown on focus. Sections and entries reorder by drag or Alt+Up/Down, rows carry the eye, count and every section option, and deleting shows Undo. Clicking the page opens the entry and scrolls it into view; focusing a field outlines its block. Phones push an open entry full screen. While a field has focus the preview waits for a pause in typing. The left section sidebar, the entry dialogs and the hidden-sections list are removed. --- apps/web/src/components/input/rich-input.tsx | 6 +- apps/web/src/dialogs/resume/registry.tsx | 51 -- .../web/src/dialogs/resume/sections/award.tsx | 158 ------ .../dialogs/resume/sections/certification.tsx | 138 ------ .../dialogs/resume/sections/cover-letter.tsx | 177 ------- .../dialogs/resume/sections/custom.test.ts | 35 -- .../src/dialogs/resume/sections/custom.tsx | 366 -------------- .../src/dialogs/resume/sections/education.tsx | 153 ------ .../dialogs/resume/sections/experience.tsx | 329 ------------- .../src/dialogs/resume/sections/interest.tsx | 202 -------- .../src/dialogs/resume/sections/language.tsx | 135 ------ .../src/dialogs/resume/sections/profile.tsx | 245 ---------- .../src/dialogs/resume/sections/project.tsx | 141 ------ .../dialogs/resume/sections/publication.tsx | 140 ------ .../src/dialogs/resume/sections/reference.tsx | 138 ------ .../resume/sections/section-item-dialog.tsx | 76 --- .../web/src/dialogs/resume/sections/skill.tsx | 240 ---------- .../dialogs/resume/sections/summary-item.tsx | 117 ----- .../src/dialogs/resume/sections/volunteer.tsx | 140 ------ apps/web/src/dialogs/schemas.ts | 35 +- apps/web/src/features/resume/builder/draft.ts | 4 + .../resume/builder/section-recovery.test.tsx | 229 --------- .../resume/builder/section-recovery.tsx | 157 ------ apps/web/src/features/resume/editor/store.ts | 30 ++ .../features/resume/editor/write/actions.ts | 24 + .../resume/editor/write/add-section.tsx | 172 +++++++ .../resume/editor/write/basics-card.tsx | 239 +++++++++ .../resume/editor/write/dates-field.test.tsx | 59 +++ .../resume/editor/write/dates-field.tsx | 173 +++++++ .../features/resume/editor/write/entries.tsx | 452 ++++++++++++++++++ .../resume/editor/write/entry-card.tsx | 340 +++++++++++++ .../features/resume/editor/write/fields.tsx | 168 +++++++ .../resume/editor/write/model.test.ts | 164 +++++++ .../src/features/resume/editor/write/model.ts | 334 +++++++++++++ .../features/resume/editor/write/outline.tsx | 216 +++++++++ .../resume/editor/write/picture-settings.tsx} | 12 +- .../features/resume/editor/write/reveal.ts | 45 ++ .../resume/editor/write/rich-text-editor.tsx | 190 ++++++++ .../resume/editor/write/section-row.tsx | 385 +++++++++++++++ .../resume/editor/write/summary-editor.tsx | 27 ++ .../resume/editor/write/write-panel.test.tsx | 134 ++++++ .../resume/editor/write/write-panel.tsx | 101 ++++ .../resume/preview/preview.browser.test.tsx | 1 + .../resume/preview/preview.browser.tsx | 7 +- .../src/libs/resume/make-section-item.test.ts | 50 -- apps/web/src/libs/resume/make-section-item.ts | 16 - .../src/libs/resume/section-actions.test.ts | 106 ---- apps/web/src/libs/resume/section-actions.ts | 42 -- apps/web/src/libs/resume/section.tsx | 2 - .../$resumeId/-components/editor-shell.tsx | 2 +- .../$resumeId/-components/mode-panels.tsx | 12 +- .../$resumeId/-components/page-canvas.tsx | 2 +- .../$resumeId/-components/reveal-selection.ts | 54 --- .../builder/$resumeId/-sidebar/left/index.tsx | 94 ---- .../-sidebar/left/sections/basics.tsx | 202 -------- .../-sidebar/left/sections/custom-fields.tsx | 172 ------- .../-sidebar/left/sections/custom.tsx | 305 ------------ .../sections/education-experience.test.tsx | 149 ------ .../left/sections/field-labels.test.tsx | 168 ------- .../left/sections/many-sections.test.tsx | 205 -------- .../-sidebar/left/sections/profiles.test.tsx | 111 ----- .../-sidebar/left/sections/projects.test.tsx | 120 ----- .../-sidebar/left/sections/skills.test.tsx | 79 --- .../-sidebar/left/sections/summary.tsx | 20 - .../-sidebar/left/shared/items-section.tsx | 147 ------ .../-sidebar/left/shared/section-base.tsx | 97 ---- .../-sidebar/left/shared/section-item.tsx | 348 -------------- .../left/shared/section-menu.test.tsx | 263 ---------- .../-sidebar/left/shared/section-menu.tsx | 254 ---------- .../shared/skill-keyword-layout-menu.test.tsx | 75 --- .../left/shared/skill-keyword-layout-menu.tsx | 51 -- .../right/sections/ats-check.test.tsx | 5 +- .../-sidebar/right/sections/ats-check.tsx | 35 +- 73 files changed, 3303 insertions(+), 6568 deletions(-) delete mode 100644 apps/web/src/dialogs/resume/sections/award.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/certification.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/cover-letter.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/custom.test.ts delete mode 100644 apps/web/src/dialogs/resume/sections/custom.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/education.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/experience.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/interest.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/language.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/profile.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/project.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/publication.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/reference.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/section-item-dialog.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/skill.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/summary-item.tsx delete mode 100644 apps/web/src/dialogs/resume/sections/volunteer.tsx delete mode 100644 apps/web/src/features/resume/builder/section-recovery.test.tsx delete mode 100644 apps/web/src/features/resume/builder/section-recovery.tsx create mode 100644 apps/web/src/features/resume/editor/write/actions.ts create mode 100644 apps/web/src/features/resume/editor/write/add-section.tsx create mode 100644 apps/web/src/features/resume/editor/write/basics-card.tsx create mode 100644 apps/web/src/features/resume/editor/write/dates-field.test.tsx create mode 100644 apps/web/src/features/resume/editor/write/dates-field.tsx create mode 100644 apps/web/src/features/resume/editor/write/entries.tsx create mode 100644 apps/web/src/features/resume/editor/write/entry-card.tsx create mode 100644 apps/web/src/features/resume/editor/write/fields.tsx create mode 100644 apps/web/src/features/resume/editor/write/model.test.ts create mode 100644 apps/web/src/features/resume/editor/write/model.ts create mode 100644 apps/web/src/features/resume/editor/write/outline.tsx rename apps/web/src/{routes/builder/$resumeId/-sidebar/left/sections/picture.tsx => features/resume/editor/write/picture-settings.tsx} (99%) create mode 100644 apps/web/src/features/resume/editor/write/reveal.ts create mode 100644 apps/web/src/features/resume/editor/write/rich-text-editor.tsx create mode 100644 apps/web/src/features/resume/editor/write/section-row.tsx create mode 100644 apps/web/src/features/resume/editor/write/summary-editor.tsx create mode 100644 apps/web/src/features/resume/editor/write/write-panel.test.tsx create mode 100644 apps/web/src/features/resume/editor/write/write-panel.tsx delete mode 100644 apps/web/src/libs/resume/make-section-item.test.ts delete mode 100644 apps/web/src/libs/resume/make-section-item.ts delete mode 100644 apps/web/src/libs/resume/section-actions.test.ts delete mode 100644 apps/web/src/libs/resume/section-actions.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/reveal-selection.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom-fields.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education-experience.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/field-labels.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/many-sections.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/summary.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/items-section.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-base.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/skill-keyword-layout-menu.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/skill-keyword-layout-menu.tsx diff --git a/apps/web/src/components/input/rich-input.tsx b/apps/web/src/components/input/rich-input.tsx index 602c5930b..c24beb563 100644 --- a/apps/web/src/components/input/rich-input.tsx +++ b/apps/web/src/components/input/rich-input.tsx @@ -414,7 +414,7 @@ const parsedTablesMatchSource = (sourceTables: readonly string[], tables: readon ); }; -const hasUnsupportedTableMarkup = (html: string) => { +export const hasUnsupportedTableMarkup = (html: string) => { if (typeof DOMParser === "undefined") return false; const tableMarkers = tableMarkersFrom(html); if (tableMarkers.length === 0) return false; @@ -491,7 +491,7 @@ const StyledTableCell = TableCell.extend({ }, }); -const extensions = [ +export const richInputExtensions = [ StarterKit.configure({ heading: false, paragraph: false, @@ -551,7 +551,7 @@ export function RichInput({ const editor = useEditor({ ...options, - extensions, + extensions: richInputExtensions, textDirection, content: value, editable: requestedEditable && !hasUnsupportedTable, diff --git a/apps/web/src/dialogs/resume/registry.tsx b/apps/web/src/dialogs/resume/registry.tsx index 9e22f9630..e9cd67194 100644 --- a/apps/web/src/dialogs/resume/registry.tsx +++ b/apps/web/src/dialogs/resume/registry.tsx @@ -1,21 +1,6 @@ import type { AnyDialogRendererEntry } from "../schemas"; import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from "."; import { ImportResumeDialog } from "./import"; -import { CreateAwardDialog, UpdateAwardDialog } from "./sections/award"; -import { CreateCertificationDialog, UpdateCertificationDialog } from "./sections/certification"; -import { CreateCoverLetterDialog, UpdateCoverLetterDialog } from "./sections/cover-letter"; -import { CreateCustomSectionDialog, UpdateCustomSectionDialog } from "./sections/custom"; -import { CreateEducationDialog, UpdateEducationDialog } from "./sections/education"; -import { CreateExperienceDialog, UpdateExperienceDialog } from "./sections/experience"; -import { CreateInterestDialog, UpdateInterestDialog } from "./sections/interest"; -import { CreateLanguageDialog, UpdateLanguageDialog } from "./sections/language"; -import { CreateProfileDialog, UpdateProfileDialog } from "./sections/profile"; -import { CreateProjectDialog, UpdateProjectDialog } from "./sections/project"; -import { CreatePublicationDialog, UpdatePublicationDialog } from "./sections/publication"; -import { CreateReferenceDialog, UpdateReferenceDialog } from "./sections/reference"; -import { CreateSkillDialog, UpdateSkillDialog } from "./sections/skill"; -import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./sections/summary-item"; -import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./sections/volunteer"; import { TemplateGalleryDialog } from "./template/gallery"; export const resumeDialogRenderers: readonly AnyDialogRendererEntry[] = [ @@ -24,40 +9,4 @@ export const resumeDialogRenderers: readonly AnyDialogRendererEntry[] = [ { type: "resume.duplicate", render: ({ data }) => }, { type: "resume.import", render: () => }, { type: "resume.template.gallery", render: () => }, - { type: "resume.sections.profiles.create", render: ({ data }) => }, - { type: "resume.sections.profiles.update", render: ({ data }) => }, - { type: "resume.sections.experience.create", render: ({ data }) => }, - { type: "resume.sections.experience.update", render: ({ data }) => }, - { type: "resume.sections.education.create", render: ({ data }) => }, - { type: "resume.sections.education.update", render: ({ data }) => }, - { type: "resume.sections.skills.create", render: ({ data }) => }, - { type: "resume.sections.skills.update", render: ({ data }) => }, - { type: "resume.sections.projects.create", render: ({ data }) => }, - { type: "resume.sections.projects.update", render: ({ data }) => }, - { - type: "resume.sections.certifications.create", - render: ({ data }) => , - }, - { - type: "resume.sections.certifications.update", - render: ({ data }) => , - }, - { type: "resume.sections.languages.create", render: ({ data }) => }, - { type: "resume.sections.languages.update", render: ({ data }) => }, - { type: "resume.sections.publications.create", render: ({ data }) => }, - { type: "resume.sections.publications.update", render: ({ data }) => }, - { type: "resume.sections.awards.create", render: ({ data }) => }, - { type: "resume.sections.awards.update", render: ({ data }) => }, - { type: "resume.sections.interests.create", render: ({ data }) => }, - { type: "resume.sections.interests.update", render: ({ data }) => }, - { type: "resume.sections.volunteer.create", render: ({ data }) => }, - { type: "resume.sections.volunteer.update", render: ({ data }) => }, - { type: "resume.sections.references.create", render: ({ data }) => }, - { type: "resume.sections.references.update", render: ({ data }) => }, - { type: "resume.sections.summary.create", render: ({ data }) => }, - { type: "resume.sections.summary.update", render: ({ data }) => }, - { type: "resume.sections.cover-letter.create", render: ({ data }) => }, - { type: "resume.sections.cover-letter.update", render: ({ data }) => }, - { type: "resume.sections.custom.create", render: ({ data }) => }, - { type: "resume.sections.custom.update", render: ({ data }) => }, ]; diff --git a/apps/web/src/dialogs/resume/sections/award.tsx b/apps/web/src/dialogs/resume/sections/award.tsx deleted file mode 100644 index 4d90a905a..000000000 --- a/apps/web/src/dialogs/resume/sections/award.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { awardItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = awardItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - title: "", - awarder: "", - date: "", - website: { url: "", label: "", inlineLink: false }, - description: "", -}; - -export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "awards", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new award} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "awards", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing award} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const AwardForm = withForm({ - defaultValues, - render: function AwardFormRenderer({ form }) { - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - - return ( - <> - {(field) => Title} />} - - - {(field) => ( - 0}> - - Awarder - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - - - {(field) => Date} />} - - - {(field) => Website} hideLabelButton={inlineLink} />} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - - {(field) => Description} formItemClassName="sm:col-span-full" />} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/certification.tsx b/apps/web/src/dialogs/resume/sections/certification.tsx deleted file mode 100644 index 0ed6ee47b..000000000 --- a/apps/web/src/dialogs/resume/sections/certification.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { certificationItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = certificationItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - title: "", - issuer: "", - date: "", - website: { url: "", label: "", inlineLink: false }, - description: "", -}; - -export function CreateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "certifications", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new certification} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "certifications", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing certification} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const CertificationForm = withForm({ - defaultValues, - render: function CertificationFormRenderer({ form }) { - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - - return ( - <> - {(field) => Title} />} - - {(field) => Issuer} />} - - {(field) => Date} />} - - - {(field) => Website} hideLabelButton={inlineLink} />} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - - {(field) => Description} formItemClassName="sm:col-span-full" />} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/cover-letter.tsx b/apps/web/src/dialogs/resume/sections/cover-letter.tsx deleted file mode 100644 index 6dcb6646a..000000000 --- a/apps/web/src/dialogs/resume/sections/cover-letter.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; -import { coverLetterItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { RichInput } from "@/components/input/rich-input"; -import { Combobox } from "@/components/ui/combobox"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { orpc } from "@/libs/orpc/client"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = coverLetterItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - recipient: "", - content: "", -}; - -export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - if (data?.customSectionId) { - const section = draft.customSections.find((s) => s.id === data.customSectionId); - if (section) section.items.push(value); - } - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new cover letter} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - singleColumn - > - { - form.setFieldValue("recipient", letter.recipient); - form.setFieldValue("content", letter.content); - }} - /> - - - ); -} - -type ImportFromLibraryProps = { - onImport: (letter: { recipient: string; content: string }) => void; -}; - -function ImportFromLibrary({ onImport }: ImportFromLibraryProps) { - const [selectedId, setSelectedId] = useState(null); - const letters = useQuery(orpc.coverLetters.list.queryOptions({ input: { limit: 100 } })); - - if (!letters.data?.items.length) return null; - - return ( - - - Import from library - - ({ value: letter.id, label: letter.name }))} - onValueChange={(id) => { - setSelectedId(id); - const letter = letters.data.items.find((item) => item.id === id); - if (letter) onImport(letter); - }} - /> - } - /> - - ); -} - -export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((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 === value.id); - if (index !== -1) section.items[index] = value; - } - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing cover letter} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - singleColumn - > - - - ); -} - -const CoverLetterForm = withForm({ - defaultValues, - render: ({ form }) => { - return ( - <> - - {(field) => ( - 0}> - - Recipient - - field.handleChange(v)} />} /> - - - )} - - - - {(field) => ( - 0}> - - Content - - field.handleChange(v)} />} /> - - - )} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/custom.test.ts b/apps/web/src/dialogs/resume/sections/custom.test.ts deleted file mode 100644 index 88fbe258d..000000000 --- a/apps/web/src/dialogs/resume/sections/custom.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; - -const source = readFileSync(new URL("./custom.tsx", import.meta.url), "utf8"); - -function rendererBody(name: string) { - const start = source.indexOf(`render: function ${name}({ form }) {`); - expect(start).toBeGreaterThanOrEqual(0); - - const end = - name === "CreateCustomSectionFormRenderer" ? source.indexOf("const UpdateCustomSectionForm", start) : source.length; - expect(end).toBeGreaterThan(start); - - return source.slice(start, end); -} - -describe("custom section dialog layout", () => { - it.each(["CreateCustomSectionFormRenderer", "UpdateCustomSectionFormRenderer"])( - "renders icon and title in one row for %s", - (name) => { - const body = rendererBody(name); - const rowIndex = body.indexOf('"flex items-end sm:col-span-full"'); - const iconIndex = body.indexOf('name="icon"', rowIndex); - const titleIndex = body.indexOf('name="title"', rowIndex); - const sectionTypeIndex = body.indexOf('name="type"', rowIndex); - - expect(rowIndex).toBeGreaterThanOrEqual(0); - expect(iconIndex).toBeGreaterThan(rowIndex); - expect(titleIndex).toBeGreaterThan(iconIndex); - expect(sectionTypeIndex).toBeGreaterThan(titleIndex); - expect(body).toContain('className="rounded-r-none border-input border-e-0"'); - expect(body).toContain('className="rounded-s-none"'); - }, - ); -}); diff --git a/apps/web/src/dialogs/resume/sections/custom.tsx b/apps/web/src/dialogs/resume/sections/custom.tsx deleted file mode 100644 index a317dcfb4..000000000 --- a/apps/web/src/dialogs/resume/sections/custom.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import type { MessageDescriptor } from "@lingui/core"; -import type { CustomSectionType } from "@reactive-resume/schema/resume/data"; -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { msg } from "@lingui/core/macro"; -import { useLingui } from "@lingui/react"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { customSectionSchema } from "@reactive-resume/schema/resume/data"; -import { Button } from "@reactive-resume/ui/components/button"; -import { - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@reactive-resume/ui/components/dialog"; -import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { generateId } from "@reactive-resume/utils/string"; -import { cn } from "@reactive-resume/utils/style"; -import { IconPicker } from "@/components/input/icon-picker"; -import { Combobox } from "@/components/ui/combobox"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { defaultSectionIconNames } from "@/libs/resume/section"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; - -const formSchema = customSectionSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - title: "", - type: "experience", - icon: "", - columns: 1, - hidden: false, - showHeading: true, - keepTogether: false, - startOnNewPage: false, - items: [], -}; - -const SECTION_TYPE_OPTIONS: { value: CustomSectionType; label: MessageDescriptor }[] = [ - { value: "summary", label: msg`Summary` }, - { value: "experience", label: msg`Experience` }, - { value: "education", label: msg`Education` }, - { value: "projects", label: msg`Projects` }, - { value: "profiles", label: msg`Profiles` }, - { value: "skills", label: msg`Skills` }, - { value: "languages", label: msg`Languages` }, - { value: "interests", label: msg`Interests` }, - { value: "awards", label: msg`Awards` }, - { value: "certifications", label: msg`Certifications` }, - { value: "publications", label: msg`Publications` }, - { value: "volunteer", label: msg`Volunteer` }, - { value: "references", label: msg`References` }, - { value: "cover-letter", label: msg`Cover Letter` }, -]; - -function isCustomSectionType(value: string | null | undefined): value is CustomSectionType { - return SECTION_TYPE_OPTIONS.some((option) => option.value === value); -} - -function getIconPickerValue(icon: string, type: CustomSectionType): string { - if (icon === "none") return ""; - - return icon || defaultSectionIconNames[type]; -} - -export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const initialValues: FormValues = { ...defaultValues, ...data, id: generateId() }; - const form = useAppForm({ - defaultValues: initialValues, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - draft.customSections.push(value); - const lastPageIndex = draft.metadata.layout.pages.length - 1; - if (lastPageIndex < 0) return; - draft.metadata.layout.pages[lastPageIndex].main.push(value.id); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - - - - - Create a new custom section - - - - -
{ - event.preventDefault(); - event.stopPropagation(); - void form.handleSubmit(); - }} - > - - - - - - - - -
- ); -} - -export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: { - ...data, - icon: data.icon ?? "", - }, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - const index = draft.customSections.findIndex((item) => item.id === value.id); - if (index === -1) return; - draft.customSections[index] = value; - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - - - - - Update an existing custom section - - - - -
{ - event.preventDefault(); - event.stopPropagation(); - void form.handleSubmit(); - }} - > - - - - - - - - -
- ); -} - -const CreateCustomSectionForm = withForm({ - defaultValues, - render: function CreateCustomSectionFormRenderer({ form }) { - const { i18n } = useLingui(); - const titleMeta = useStore(form.store, (state) => state.fieldMeta?.title); - const sectionType = useStore(form.store, (state) => state.values.type); - - const isTitleInvalid = (titleMeta?.isTouched ?? false) && (titleMeta?.errors?.length ?? 0) > 0; - - return ( - <> -
- - {(field) => ( - 0} - > - { - field.handleChange(icon === "" ? "none" : icon); - }} - className="rounded-r-none border-input border-e-0" - /> - } - /> - - )} - - - - {(field) => ( - 0}> - - Title - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - -
- - - {(field) => ( - 0} - > - - Section Type - - { - if (isCustomSectionType(v)) field.handleChange(v); - }} - options={SECTION_TYPE_OPTIONS.map((option) => ({ - value: option.value, - label: i18n.t(option.label), - }))} - /> - } - /> - - - )} - - - ); - }, -}); - -const UpdateCustomSectionForm = withForm({ - defaultValues, - render: function UpdateCustomSectionFormRenderer({ form }) { - const { i18n } = useLingui(); - const titleMeta = useStore(form.store, (state) => state.fieldMeta?.title); - const sectionType = useStore(form.store, (state) => state.values.type); - - const isTitleInvalid = (titleMeta?.isTouched ?? false) && (titleMeta?.errors?.length ?? 0) > 0; - - return ( - <> -
- - {(field) => ( - 0} - > - { - field.handleChange(icon === "" ? "none" : icon); - }} - className="rounded-r-none border-input border-e-0" - /> - } - /> - - )} - - - - {(field) => ( - 0}> - - Title - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - -
- - - {(field) => ( - 0} - > - - Section Type - - { - if (isCustomSectionType(v)) field.handleChange(v); - }} - options={SECTION_TYPE_OPTIONS.map((option) => ({ - value: option.value, - label: i18n.t(option.label), - }))} - /> - } - /> - - - )} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/education.tsx b/apps/web/src/dialogs/resume/sections/education.tsx deleted file mode 100644 index e5c982c9e..000000000 --- a/apps/web/src/dialogs/resume/sections/education.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { educationItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = educationItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - school: "", - degree: "", - area: "", - grade: "", - location: "", - period: "", - website: { url: "", label: "", inlineLink: false }, - description: "", -}; - -export function CreateEducationDialog({ data }: DialogProps<"resume.sections.education.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "education", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new education} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.education.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "education", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing education} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const EducationForm = withForm({ - defaultValues, - render: function EducationFormRenderer({ form }) { - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - - return ( - <> - {(field) => School} />} - - {(field) => Area of Study} />} - - {(field) => Degree} />} - - {(field) => Grade} />} - - {(field) => Location} />} - - {(field) => Period} />} - - - {(field) => ( - Website} - formItemClassName="sm:col-span-full" - hideLabelButton={inlineLink} - /> - )} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - - {(field) => Description} formItemClassName="sm:col-span-full" />} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/experience.tsx b/apps/web/src/dialogs/resume/sections/experience.tsx deleted file mode 100644 index 70751b378..000000000 --- a/apps/web/src/dialogs/resume/sections/experience.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import type { RoleItem } from "@reactive-resume/schema/resume/data"; -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon, RowsIcon, TrashSimpleIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { AnimatePresence, Reorder, useDragControls } from "motion/react"; -import { experienceItemSchema } from "@reactive-resume/schema/resume/data"; -import { Button } from "@reactive-resume/ui/components/button"; -import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { generateId } from "@reactive-resume/utils/string"; -import { RichInput } from "@/components/input/rich-input"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { EASE_OUT_STRONG } from "@/libs/motion"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = experienceItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - company: "", - position: "", - location: "", - period: "", - website: { url: "", label: "", inlineLink: false }, - description: "", - roles: [] as RoleItem[], -}; - -export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.experience.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "experience", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new experience} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.experience.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "experience", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing experience} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const ExperienceForm = withForm({ - defaultValues, - render: function ExperienceFormRenderer({ form }) { - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - const roles = useStore(form.store, (s) => s.values.roles); - const hasRoles = roles.length > 0; - - const handleReorderRoles = (newOrder: RoleItem[]) => { - form.setFieldValue("roles", newOrder); - }; - - return ( - <> - {(field) => Company} />} - - {(field) => Location} />} - - {(field) => Position} />} - - {(field) => Period} />} - - - {(field) => ( - Website} - formItemClassName="sm:col-span-full" - hideLabelButton={inlineLink} - /> - )} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - {/* Role Progression */} -
-
-

- Role Progression -

-

- Add multiple roles to show career progression at the same company. -

-
- - -
- - {hasRoles && ( - - {(rolesField) => ( - - - {rolesField.state.value.map((role: RoleItem, index: number) => ( - { - rolesField.removeValue(index); - }} - /> - ))} - - - )} - - )} - - {/* Single Role Description — only show when no roles are defined */} - {!hasRoles && ( - - {(field) => Description} formItemClassName="sm:col-span-full" />} - - )} - - ); - }, -}); - -const RoleFields = withForm({ - defaultValues, - props: { - role: { - id: "", - position: "", - period: "", - description: "", - } as RoleItem, - index: 0, - onRemove: () => undefined, - }, - render: function RoleFieldsRenderer({ form, role, index, onRemove }) { - const controls = useDragControls(); - - return ( - -
- - - -
- -
- - {(field) => ( - 0}> - - Position - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - - - - {(field) => ( - 0}> - - Period - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - - - - {(field) => ( - 0} - > - - Description - - field.handleChange(v)} />} /> - - - )} - -
-
- ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/interest.tsx b/apps/web/src/dialogs/resume/sections/interest.tsx deleted file mode 100644 index 7005c08ba..000000000 --- a/apps/web/src/dialogs/resume/sections/interest.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { interestItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { PopoverTrigger } from "@reactive-resume/ui/components/popover"; -import { cn } from "@reactive-resume/utils/style"; -import { ChipInput } from "@/components/input/chip-input"; -import { ColorPicker } from "@/components/input/color-picker"; -import { IconPicker } from "@/components/input/icon-picker"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = interestItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - icon: "acorn", - iconColor: "", - name: "", - keywords: [], -}; - -export function CreateInterestDialog({ data }: DialogProps<"resume.sections.interests.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "interests", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new interest} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.interests.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "interests", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing interest} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const InterestForm = withForm({ - defaultValues, - render: function InterestFormRenderer({ form }) { - const nameMeta = useStore(form.store, (s) => s.fieldMeta?.name); - - const isNameInvalid = (nameMeta?.isTouched ?? false) && (nameMeta?.errors?.length ?? 0) > 0; - - return ( - <> -
- - {(field) => ( - - { - field.handleChange(icon); - }} - popoverProps={{ modal: true }} - className="rounded-r-none border-input border-e-0" - /> - } - /> - - )} - - - - {(field) => ( - 0}> - - Name - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - - - - {(field) => ( - - { - field.handleChange(v); - }} - trigger={ - -
- - } - /> - } - /> - - )} - -
- - - {(field) => ( - 0} - > - - Keywords - - { - field.handleChange(v); - }} - /> - } - /> - - - )} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/language.tsx b/apps/web/src/dialogs/resume/sections/language.tsx deleted file mode 100644 index 5d7ff45e1..000000000 --- a/apps/web/src/dialogs/resume/sections/language.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { languageItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Slider } from "@reactive-resume/ui/components/slider"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = languageItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - language: "", - fluency: "", - level: 0, -}; - -export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.languages.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "languages", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new language} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.languages.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "languages", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing language} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const LanguageForm = withForm({ - defaultValues, - render: ({ form }) => { - return ( - <> - {(field) => Language} />} - - {(field) => Fluency} />} - - - {(field) => ( - 0} - > - - Level - - { - field.handleChange(Array.isArray(value) ? value[0] : value); - }} - /> - } - /> - - - {Number(field.state.value) === 0 ? t`Hidden` : `${field.state.value} / 5`} - - - )} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/profile.tsx b/apps/web/src/dialogs/resume/sections/profile.tsx deleted file mode 100644 index 3ca03e175..000000000 --- a/apps/web/src/dialogs/resume/sections/profile.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { AtIcon, PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { profileItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, - InputGroupText, -} from "@reactive-resume/ui/components/input-group"; -import { PopoverTrigger } from "@reactive-resume/ui/components/popover"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { cn } from "@reactive-resume/utils/style"; -import { ColorPicker } from "@/components/input/color-picker"; -import { IconPicker } from "@/components/input/icon-picker"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = profileItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - icon: "acorn", - iconColor: "", - network: "", - username: "", - website: { url: "", label: "", inlineLink: false }, -}; - -export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profiles.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "profiles", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new profile} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profiles.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "profiles", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing profile} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const ProfileForm = withForm({ - defaultValues, - render: function ProfileFormRenderer({ form }) { - const networkMeta = useStore(form.store, (s) => s.fieldMeta?.network); - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - - const isNetworkInvalid = (networkMeta?.isTouched ?? false) && (networkMeta?.errors?.length ?? 0) > 0; - - return ( - <> -
- - {(field) => ( - - { - field.handleChange(icon); - }} - popoverProps={{ modal: true }} - className="rounded-r-none border-input border-e-0" - /> - } - /> - - )} - - - - {(field) => ( - 0}> - - Network - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - - - - {(field) => ( - - { - field.handleChange(v); - }} - trigger={ - -
- - } - /> - } - /> - - )} - -
- - - {(field) => ( - 0}> - - Username - - - - - - - - - field.handleChange(event.target.value)} - /> - } - /> - - - - )} - - - - {(field) => ( - Website} - formItemClassName="sm:col-span-full" - hideLabelButton={inlineLink} - /> - )} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/project.tsx b/apps/web/src/dialogs/resume/sections/project.tsx deleted file mode 100644 index ae9fd43bd..000000000 --- a/apps/web/src/dialogs/resume/sections/project.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { projectItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = projectItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - name: "", - period: "", - website: { url: "", label: "", inlineLink: false }, - description: "", -}; - -export function CreateProjectDialog({ data }: DialogProps<"resume.sections.projects.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "projects", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new project} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.projects.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "projects", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing project} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const ProjectForm = withForm({ - defaultValues, - render: function ProjectFormRenderer({ form }) { - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - - return ( - <> - {(field) => Name} />} - - {(field) => Period} />} - - - {(field) => ( - Website} - formItemClassName="sm:col-span-full" - hideLabelButton={inlineLink} - /> - )} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - - {(field) => Description} formItemClassName="sm:col-span-full" />} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/publication.tsx b/apps/web/src/dialogs/resume/sections/publication.tsx deleted file mode 100644 index bed592372..000000000 --- a/apps/web/src/dialogs/resume/sections/publication.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { publicationItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = publicationItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - title: "", - publisher: "", - date: "", - website: { url: "", label: "", inlineLink: false }, - description: "", -}; - -export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.publications.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "publications", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new publication} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.publications.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "publications", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing publication} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const PublicationForm = withForm({ - defaultValues, - render: function PublicationFormRenderer({ form }) { - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - - return ( - <> - {(field) => Title} />} - - - {(field) => Publisher} />} - - - {(field) => Date} />} - - - {(field) => Website} hideLabelButton={inlineLink} />} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - - {(field) => Description} formItemClassName="sm:col-span-full" />} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/reference.tsx b/apps/web/src/dialogs/resume/sections/reference.tsx deleted file mode 100644 index 69e37a55e..000000000 --- a/apps/web/src/dialogs/resume/sections/reference.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { referenceItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = referenceItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - name: "", - position: "", - website: { url: "", label: "", inlineLink: false }, - phone: "", - description: "", -}; - -export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.references.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "references", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new reference} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.references.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "references", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing reference} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const ReferenceForm = withForm({ - defaultValues, - render: function ReferenceFormRenderer({ form }) { - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - - return ( - <> - {(field) => Name} />} - - {(field) => Position} />} - - {(field) => Phone} />} - - - {(field) => Website} hideLabelButton={inlineLink} />} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - - {(field) => Description} formItemClassName="sm:col-span-full" />} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/section-item-dialog.tsx b/apps/web/src/dialogs/resume/sections/section-item-dialog.tsx deleted file mode 100644 index 7324f93ec..000000000 --- a/apps/web/src/dialogs/resume/sections/section-item-dialog.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { ReactNode } from "react"; -import { Trans } from "@lingui/react/macro"; -import { Button } from "@reactive-resume/ui/components/button"; -import { - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@reactive-resume/ui/components/dialog"; - -type SectionItemDialogProps = { - /** Full dialog title — pass a Trans node so i18n extraction works per-file */ - title: ReactNode; - /** Leading icon — PlusIcon (create) or PencilSimpleLineIcon (update) */ - icon: ReactNode; - /** Called via `void form.handleSubmit()` from the form's onSubmit handler */ - onSubmit: () => void; - /** Called by useFormBlocker's requestClose */ - onCancel: () => void; - isSubmitting: boolean; - /** Button label: Create or Save Changes */ - submitLabel: ReactNode; - /** When true, uses a single-column form layout (cover-letter, summary-item) */ - singleColumn?: boolean; - children: ReactNode; -}; - -/** - * Shared shell for section-item Create/Update dialogs. - * Each dialog still owns its schema, defaultValues, and form hooks. - * This wrapper only removes the duplicated DialogContent/Header/form/Footer boilerplate. - */ -export function SectionItemDialog({ - title, - icon, - onSubmit, - onCancel, - isSubmitting, - submitLabel, - singleColumn = false, - children, -}: SectionItemDialogProps) { - return ( - - - - {icon} - {title} - - - - -
{ - event.preventDefault(); - event.stopPropagation(); - onSubmit(); - }} - > - {children} - - - - - - -
-
- ); -} diff --git a/apps/web/src/dialogs/resume/sections/skill.tsx b/apps/web/src/dialogs/resume/sections/skill.tsx deleted file mode 100644 index 38db16ff2..000000000 --- a/apps/web/src/dialogs/resume/sections/skill.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { skillItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { PopoverTrigger } from "@reactive-resume/ui/components/popover"; -import { Slider } from "@reactive-resume/ui/components/slider"; -import { cn } from "@reactive-resume/utils/style"; -import { ChipInput } from "@/components/input/chip-input"; -import { ColorPicker } from "@/components/input/color-picker"; -import { IconPicker } from "@/components/input/icon-picker"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = skillItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - icon: "acorn", - iconColor: "", - name: "", - proficiency: "", - level: 0, - keywords: [], -}; - -export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "skills", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new skill} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "skills", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing skill} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const SkillForm = withForm({ - defaultValues, - render: function SkillFormRenderer({ form }) { - const nameMeta = useStore(form.store, (s) => s.fieldMeta?.name); - - const isNameInvalid = (nameMeta?.isTouched ?? false) && (nameMeta?.errors?.length ?? 0) > 0; - - return ( - <> -
- - {(field) => ( - - { - field.handleChange(icon); - }} - popoverProps={{ modal: true }} - className="rounded-r-none border-input border-e-0" - /> - } - /> - - )} - - - - {(field) => ( - 0}> - - Name - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - - - - {(field) => ( - - { - field.handleChange(v); - }} - trigger={ - -
- - } - /> - } - /> - - )} - -
- - - {(field) => Proficiency} />} - - - - {(field) => ( - 0} - > - - Level - - { - field.handleChange(Array.isArray(value) ? value[0] : value); - }} - /> - } - /> - - - {Number(field.state.value) === 0 ? t`Hidden` : `${field.state.value} / 5`} - - - )} - - - - {(field) => ( - 0} - > - - Keywords - - { - field.handleChange(v); - }} - /> - } - /> - - - )} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/summary-item.tsx b/apps/web/src/dialogs/resume/sections/summary-item.tsx deleted file mode 100644 index a370f8c2b..000000000 --- a/apps/web/src/dialogs/resume/sections/summary-item.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { summaryItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { RichInput } from "@/components/input/rich-input"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = summaryItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - content: "", -}; - -export function CreateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - if (data?.customSectionId) { - const section = draft.customSections.find((s) => s.id === data.customSectionId); - if (section) section.items.push(value); - } - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new summary item} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - singleColumn - > - - - ); -} - -export function UpdateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeStore = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - 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 === value.id); - if (index !== -1) section.items[index] = value; - } - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing summary item} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - singleColumn - > - - - ); -} - -const SummaryItemForm = withForm({ - defaultValues, - render: ({ form }) => { - return ( - - {(field) => ( - 0}> - - Content - - field.handleChange(v)} />} /> - - - )} - - ); - }, -}); diff --git a/apps/web/src/dialogs/resume/sections/volunteer.tsx b/apps/web/src/dialogs/resume/sections/volunteer.tsx deleted file mode 100644 index b45eb7f6b..000000000 --- a/apps/web/src/dialogs/resume/sections/volunteer.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import type z from "zod"; -import type { DialogProps } from "@/dialogs/store"; -import { Trans } from "@lingui/react/macro"; -import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; -import { volunteerItemSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { useDialogStore } from "@/dialogs/store"; -import { useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { makeSectionItem } from "@/libs/resume/make-section-item"; -import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions"; -import { useAppForm, withForm } from "@/libs/tanstack-form"; -import { SectionItemDialog } from "./section-item-dialog"; - -const formSchema = volunteerItemSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - id: "", - hidden: false, - organization: "", - location: "", - period: "", - website: { url: "", label: "", inlineLink: false }, - description: "", -}; - -export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.create">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: makeSectionItem(defaultValues, data?.item), - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - createSectionItem(draft, "volunteer", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Create a new volunteer experience} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Create} - > - - - ); -} - -export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.update">) { - const closeDialog = useDialogStore((state) => state.closeDialog); - const updateResumeData = useUpdateResumeData(); - - const form = useAppForm({ - defaultValues: data.item, - validators: { onSubmit: formSchema }, - onSubmit: ({ value }) => { - updateResumeData((draft) => { - updateSectionItem(draft, "volunteer", value, data?.customSectionId); - }); - closeDialog(); - }, - }); - - const { requestClose } = useFormBlocker(form); - const isSubmitting = useStore(form.store, (state) => state.isSubmitting); - - return ( - Update an existing volunteer experience} - icon={} - onSubmit={() => void form.handleSubmit()} - onCancel={requestClose} - isSubmitting={isSubmitting} - submitLabel={Save Changes} - > - - - ); -} - -const VolunteerForm = withForm({ - defaultValues, - render: function VolunteerFormRenderer({ form }) { - const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink); - - return ( - <> - - {(field) => Organization} />} - - - {(field) => Location} />} - - {(field) => Period} />} - - - {(field) => Website} hideLabelButton={inlineLink} />} - - - - {(field) => ( - - { - field.handleChange(checked); - }} - /> - } - /> - - Show link in title - - - )} - - - - {(field) => Description} formItemClassName="sm:col-span-full" />} - - - ); - }, -}); diff --git a/apps/web/src/dialogs/schemas.ts b/apps/web/src/dialogs/schemas.ts index 824bd9802..2471d6771 100644 --- a/apps/web/src/dialogs/schemas.ts +++ b/apps/web/src/dialogs/schemas.ts @@ -1,37 +1,7 @@ -import type { CoverLetterItem, CustomSection, SectionItem, SummaryItem } from "@reactive-resume/schema/resume/data"; import type { ReactNode } from "react"; type EmptyDialog = { [K in T]: { type: K; data?: undefined } }[T]; -type SectionItems = { - profiles: SectionItem<"profiles">; - experience: SectionItem<"experience">; - education: SectionItem<"education">; - projects: SectionItem<"projects">; - skills: SectionItem<"skills">; - languages: SectionItem<"languages">; - interests: SectionItem<"interests">; - awards: SectionItem<"awards">; - certifications: SectionItem<"certifications">; - publications: SectionItem<"publications">; - volunteer: SectionItem<"volunteer">; - references: SectionItem<"references">; - summary: SummaryItem; - "cover-letter": CoverLetterItem; -}; - -type SectionDialog = { - [T in keyof SectionItems]: - | { - type: `resume.sections.${T}.create`; - data?: { item?: SectionItems[T]; customSectionId?: string }; - } - | { - type: `resume.sections.${T}.update`; - data: { item: SectionItems[T]; customSectionId?: string }; - }; -}[keyof SectionItems]; - export type DialogSchema = | EmptyDialog< | "auth.change-password" @@ -49,10 +19,7 @@ export type DialogSchema = | { type: "resume.duplicate"; data: { id: string; name: string; slug: string; tags: string[]; shouldRedirect?: boolean }; - } - | SectionDialog - | { type: "resume.sections.custom.create"; data?: CustomSection } - | { type: "resume.sections.custom.update"; data: CustomSection }; + }; export type DialogType = DialogSchema["type"]; diff --git a/apps/web/src/features/resume/builder/draft.ts b/apps/web/src/features/resume/builder/draft.ts index a12704e56..388998772 100644 --- a/apps/web/src/features/resume/builder/draft.ts +++ b/apps/web/src/features/resume/builder/draft.ts @@ -9,6 +9,7 @@ import { debounce, isEqual } from "es-toolkit"; import { useCallback, useEffect, useState } from "react"; import { immer } from "zustand/middleware/immer"; import { create } from "zustand/react"; +import { syncResumeDates } from "@reactive-resume/schema/resume/dates"; import { toast } from "@reactive-resume/ui/components/toast"; import { orpc, streamClient } from "@/libs/orpc/client"; @@ -551,6 +552,9 @@ export const useResumeStore = create()( } fn(state.resume.data as WritableDraft); + // The server keeps dates and their legacy text in step on save; doing the same here keeps its + // echo identical to the draft, so an autosave never reads as an outside change. + syncResumeDates(state.resume.data as ResumeData, currentResume.data); state.saveStatus = "saving"; state.canUndo = state.undoStack.length > 0; state.canRedo = state.redoStack.length > 0; diff --git a/apps/web/src/features/resume/builder/section-recovery.test.tsx b/apps/web/src/features/resume/builder/section-recovery.test.tsx deleted file mode 100644 index 446ea3833..000000000 --- a/apps/web/src/features/resume/builder/section-recovery.test.tsx +++ /dev/null @@ -1,229 +0,0 @@ -// @vitest-environment happy-dom - -import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import type { Resume } from "./draft"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; -import { ConfirmDialogProvider } from "@/hooks/use-confirm"; -import { CustomSectionBuilder } from "@/routes/builder/$resumeId/-sidebar/left/sections/custom"; -import { useResumeStore } from "./draft"; -import { - focusLeftSidebarSection, - getVisibleLeftSidebarSections, - SectionEditorList, - SectionRecovery, -} from "./section-recovery"; - -const routerParams = vi.hoisted(() => ({ resumeId: "section-recovery" })); - -vi.mock("@tanstack/react-router", () => ({ - useParams: () => routerParams, -})); - -vi.mock("@/libs/orpc/client", () => ({ - orpc: { - resume: { - getById: { queryOptions: () => ({ queryKey: ["resume", "section-recovery"] }) }, - patch: { call: vi.fn() }, - update: { call: vi.fn(() => new Promise(() => undefined)) }, - }, - }, - streamClient: { resume: { updates: { subscribe: vi.fn() } } }, -})); - -vi.mock("@reactive-resume/ui/components/toast", () => ({ - toast: { add: vi.fn(), close: vi.fn() }, -})); - -function makeResume(data: ResumeData, isLocked = false): Resume { - return { - id: routerParams.resumeId, - name: "Section Recovery", - slug: "section-recovery", - tags: [], - data, - isLocked, - updatedAt: new Date("2026-09-06T00:00:00.000Z"), - }; -} - -function makeHiddenData(): ResumeData { - const data = structuredClone(sampleResumeData); - data.summary.hidden = true; - data.sections.experience.hidden = true; - data.sections.experience.title = "Work History"; - data.customSections[0].hidden = true; - data.customSections[0].title = "Earlier Roles"; - return data; -} - -function renderRecovery(data = makeHiddenData(), isLocked = false) { - useResumeStore.getState().initialize(makeResume(data, isLocked)); - return render( - - -
- -
-
-
, - ); -} - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -beforeEach(() => { - vi.useFakeTimers(); - useResumeStore.getState().reset(); -}); - -afterEach(() => { - cleanup(); - vi.clearAllTimers(); - vi.useRealTimers(); - useResumeStore.getState().reset(); -}); - -describe("hidden section recovery", () => { - it("keeps Picture, Basics, and Custom editors while removing hidden printable editors", () => { - const visible = getVisibleLeftSidebarSections(makeHiddenData()); - - expect(visible).toContain("picture"); - expect(visible).toContain("basics"); - expect(visible).toContain("custom"); - expect(visible).not.toContain("summary"); - expect(visible).not.toContain("experience"); - }); - - it("does not mount full editors for hidden printable sections", () => { - useResumeStore.getState().initialize(makeResume(makeHiddenData())); - render( - - -
} /> - - , - ); - - expect(screen.getByTestId("editor-picture")).toBeInTheDocument(); - expect(screen.getByTestId("editor-basics")).toBeInTheDocument(); - expect(screen.getByTestId("editor-custom")).toBeInTheDocument(); - expect(screen.queryByTestId("editor-summary")).not.toBeInTheDocument(); - expect(screen.queryByTestId("editor-experience")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument(); - }); - - it("keeps the custom editor container while omitting only hidden custom children", () => { - const data = makeHiddenData(); - useResumeStore.getState().initialize(makeResume(data)); - render( - - - - - - - , - ); - - expect(screen.queryByText("Earlier Roles")).not.toBeInTheDocument(); - expect(screen.getAllByText("Cover Letter").length).toBeGreaterThan(0); - expect(screen.getByRole("button", { name: "Add a new custom section" })).toBeInTheDocument(); - }); - - it("lists hidden built-in, summary, and custom sections by effective title", () => { - renderRecovery(); - - expect(screen.getByRole("region", { name: "Hidden sections" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Show Summary section" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Show Earlier Roles section" })).toBeInTheDocument(); - }); - - it("shows via keyboard, changes only hidden state, and participates in undo and redo", async () => { - const data = makeHiddenData(); - const before = structuredClone(data); - vi.useRealTimers(); - const user = userEvent.setup(); - renderRecovery(data); - const show = screen.getByRole("button", { name: "Show Work History section" }); - - show.focus(); - await user.keyboard("{Enter}"); - - const shown = useResumeStore.getState().resume?.data; - expect(shown?.sections.experience.hidden).toBe(false); - expect({ ...shown?.sections.experience, hidden: true }).toEqual(before.sections.experience); - expect(shown?.metadata.layout).toEqual(before.metadata.layout); - expect(screen.queryByRole("button", { name: "Show Work History section" })).not.toBeInTheDocument(); - - act(() => useResumeStore.getState().undo()); - expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true); - expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument(); - - act(() => useResumeStore.getState().redo()); - expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false); - }); - - it("shows an unplaced hidden section without choosing a layout location", () => { - const data = makeHiddenData(); - for (const page of data.metadata.layout.pages) { - page.main = page.main.filter((id) => id !== "experience"); - page.sidebar = page.sidebar.filter((id) => id !== "experience"); - } - const layoutBefore = structuredClone(data.metadata.layout); - renderRecovery(data); - - act(() => screen.getByRole("button", { name: "Show Work History section" }).click()); - - expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false); - expect(useResumeStore.getState().resume?.data.metadata.layout).toEqual(layoutBefore); - }); - - it("disables recovery actions for a locked resume", () => { - renderRecovery(makeHiddenData(), true); - const show = screen.getByRole("button", { name: "Show Work History section" }); - - expect(show).toBeDisabled(); - show.click(); - expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true); - expect(useResumeStore.getState().undoStack).toHaveLength(0); - }); - - it("reopens a collapsed recovery group before focusing and scrolling the hidden section", async () => { - vi.useRealTimers(); - const user = userEvent.setup(); - const scrollIntoView = vi.fn(); - Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { - configurable: true, - value: scrollIntoView, - }); - renderRecovery(); - const trigger = screen.getByRole("button", { name: "Hidden sections" }); - - await user.click(trigger); - await waitFor(() => expect(document.getElementById("sidebar-hidden-experience")).toBeNull()); - - focusLeftSidebarSection("experience"); - - await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true")); - const recoveryEntry = await waitFor(() => { - const entry = document.getElementById("sidebar-hidden-experience"); - expect(entry).not.toBeNull(); - return entry; - }); - await waitFor(() => expect(recoveryEntry).toHaveFocus()); - expect(scrollIntoView).toHaveBeenCalledWith({ - block: "start", - inline: "nearest", - behavior: "smooth", - }); - }); -}); diff --git a/apps/web/src/features/resume/builder/section-recovery.tsx b/apps/web/src/features/resume/builder/section-recovery.tsx deleted file mode 100644 index ad673c9fa..000000000 --- a/apps/web/src/features/resume/builder/section-recovery.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data"; -import type { ReactNode } from "react"; -import type { LeftSidebarSection } from "@/libs/resume/section"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { EyeClosedIcon, EyeIcon } from "@phosphor-icons/react"; -import { Fragment } from "react"; -import { getSectionAvailability } from "@reactive-resume/resume/section-availability"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion"; -import { Button } from "@reactive-resume/ui/components/button"; -import { Separator } from "@reactive-resume/ui/components/separator"; -import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { leftSidebarSections } from "@/libs/resume/section"; -import { resolveLayoutSectionTitle } from "@/routes/builder/$resumeId/-sidebar/right/sections/layout/title"; - -export function getVisibleLeftSidebarSections(data: ResumeData): LeftSidebarSection[] { - const hiddenSectionIds = new Set( - getSectionAvailability(data) - .filter((section) => section.hidden) - .map((section) => section.sectionId), - ); - - return leftSidebarSections.filter( - (section) => - section === "picture" || section === "basics" || section === "custom" || !hiddenSectionIds.has(section), - ); -} - -/** Smooth scrolling unless the user asked for reduced motion. */ -export function getScrollBehavior(): ScrollBehavior { - return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"; -} - -function focusSidebarSection(sectionId: string): void { - const editorTarget = document.getElementById(`sidebar-${sectionId}`); - if (editorTarget) { - editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() }); - return; - } - - const recoveryTargetId = `sidebar-hidden-${sectionId}`; - const focusRecoveryTarget = () => { - const recoveryTarget = document.getElementById(recoveryTargetId); - if (!recoveryTarget) return; - - recoveryTarget.focus({ preventScroll: true }); - recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() }); - }; - - const trigger = document.getElementById("sidebar-hidden-sections-trigger"); - if (trigger?.getAttribute("aria-expanded") === "false") { - trigger.click(); - requestAnimationFrame(focusRecoveryTarget); - return; - } - - focusRecoveryTarget(); -} - -export function focusLeftSidebarSection(section: LeftSidebarSection): void { - focusSidebarSection(section); -} - -export function focusCustomSidebarSection(sectionId: string): void { - focusSidebarSection(sectionId); -} - -type SectionEditorListProps = { - renderSection: (section: LeftSidebarSection) => ReactNode; -}; - -export function SectionEditorList({ renderSection }: SectionEditorListProps) { - const sectionKey = useCurrentBuilderResumeSelector((resume) => getVisibleLeftSidebarSections(resume.data).join(",")); - const sections = sectionKey.split(",") as LeftSidebarSection[]; - - return ( - <> - {sections.map((section) => ( - - {renderSection(section)} - - - ))} - - - ); -} - -export function SectionRecovery() { - const data = useCurrentBuilderResumeSelector((resume) => resume.data); - const updateResumeData = useUpdateResumeData(); - const hiddenSections = getSectionAvailability(data).filter((section) => section.hidden); - - if (hiddenSections.length === 0) return null; - - const showSection = (sectionId: string) => { - updateResumeData((draft) => { - if (sectionId === "summary") { - draft.summary.hidden = false; - return; - } - - if (Object.hasOwn(draft.sections, sectionId)) { - draft.sections[sectionId as SectionType].hidden = false; - return; - } - - const customSection = draft.customSections.find((section) => section.id === sectionId); - if (customSection) customSection.hidden = false; - }); - }; - - return ( -
- - - - - - - -
    - {hiddenSections.map(({ sectionId }) => { - const title = resolveLayoutSectionTitle(data, sectionId); - - return ( -
  • - {title} - -
  • - ); - })} -
-
-
-
-
- ); -} diff --git a/apps/web/src/features/resume/editor/store.ts b/apps/web/src/features/resume/editor/store.ts index 8693e061c..aa9979470 100644 --- a/apps/web/src/features/resume/editor/store.ts +++ b/apps/web/src/features/resume/editor/store.ts @@ -24,6 +24,14 @@ type EditorStore = { shareOpen: boolean; downloadOpen: boolean; assistantOpen: boolean; + /** Write: sections open in the outline. */ + openSections: readonly string[]; + /** Write: sections added this visit that are still empty (a summary before any text), so they stay listed. */ + addedSections: readonly string[]; + /** Write: a new draft whose first field takes focus once it renders. */ + focusEntryId: string | null; + /** Write: the Basics card. It collapses when an entry is picked on the page. */ + basicsOpen: boolean; select: (selection: EditorSelection | null) => void; setZoom: (zoom: number | "fit") => void; setDrawerOpen: (open: boolean) => void; @@ -31,6 +39,10 @@ type EditorStore = { setShareOpen: (open: boolean) => void; setDownloadOpen: (open: boolean) => void; setAssistantOpen: (open: boolean) => void; + setSectionOpen: (sectionId: string, open: boolean) => void; + markSectionAdded: (sectionId: string) => void; + setFocusEntry: (entryId: string | null) => void; + setBasicsOpen: (open: boolean) => void; reset: () => void; }; @@ -54,6 +66,10 @@ const initialState = { shareOpen: false, downloadOpen: false, assistantOpen: false, + openSections: [], + addedSections: [], + focusEntryId: null, + basicsOpen: true, } as const; export const useEditorStore = create()((set) => ({ @@ -65,5 +81,19 @@ export const useEditorStore = create()((set) => ({ setShareOpen: (shareOpen) => set({ shareOpen }), setDownloadOpen: (downloadOpen) => set({ downloadOpen }), setAssistantOpen: (assistantOpen) => set({ assistantOpen }), + setSectionOpen: (sectionId, open) => + set((state) => { + const isOpen = state.openSections.includes(sectionId); + if (isOpen === open) return state; + return { + openSections: open ? [...state.openSections, sectionId] : state.openSections.filter((id) => id !== sectionId), + }; + }), + markSectionAdded: (sectionId) => + set((state) => + state.addedSections.includes(sectionId) ? state : { addedSections: [...state.addedSections, sectionId] }, + ), + setFocusEntry: (focusEntryId) => set({ focusEntryId }), + setBasicsOpen: (basicsOpen) => set({ basicsOpen }), reset: () => set(initialState), })); diff --git a/apps/web/src/features/resume/editor/write/actions.ts b/apps/web/src/features/resume/editor/write/actions.ts new file mode 100644 index 000000000..6e996fca2 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/actions.ts @@ -0,0 +1,24 @@ +import type { WriteSection } from "./model"; +import { useResumeStore } from "@/features/resume/builder/draft"; +import { useEditorStore } from "../store"; +import { createEntry, getEntries } from "./model"; + +/** Opens an entry: expands its section, selects it (outlining it on the page) and focuses a new draft. */ +export function openEntry(sectionId: string, entryId: string, options: { focus?: boolean } = {}) { + const editor = useEditorStore.getState(); + editor.setSectionOpen(sectionId, true); + editor.select({ kind: "item", sectionId, itemId: entryId }); + if (options.focus) editor.setFocusEntry(entryId); +} + +/** "+ Add {type}": a draft entry at the end of the section, open and focused. */ +export function addEntryTo(section: WriteSection) { + const entry = createEntry(section.type); + useResumeStore.getState().updateResumeData( + (draft) => { + getEntries(draft, section).push(entry); + }, + { newStep: true }, + ); + openEntry(section.id, entry.id, { focus: true }); +} diff --git a/apps/web/src/features/resume/editor/write/add-section.tsx b/apps/web/src/features/resume/editor/write/add-section.tsx new file mode 100644 index 000000000..53b8f6f62 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/add-section.tsx @@ -0,0 +1,172 @@ +import type { CustomSectionType, SectionType } from "@reactive-resume/schema/resume/data"; +import type { IconName } from "@reactive-resume/ui/components/icon"; +import { Trans } from "@lingui/react/macro"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@reactive-resume/ui/components/dropdown-menu"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { useCurrentBuilderResumeSelector, useResumeStore } from "@/features/resume/builder/draft"; +import { getSectionTitle } from "@/libs/resume/section"; +import { useEditorStore } from "../store"; +import { openEntry } from "./actions"; +import { + addBuiltinSection, + addCustomSection, + BUILTIN_SECTION_TYPES, + CUSTOM_SECTION_TYPES, + isSectionInUse, +} from "./model"; + +const SECTION_ICONS: Record = { + summary: "short_text", + experience: "work", + education: "school", + projects: "hub", + skills: "bolt", + languages: "translate", + interests: "bookmark", + awards: "check_circle", + certifications: "fact_check", + publications: "description", + volunteer: "account_circle", + references: "call", + profiles: "link", + "cover-letter": "mail", +}; + +/** Adds a built-in section (or the summary), opens it and focuses its first field. */ +function addSection(type: "summary" | SectionType) { + let entryId: string | null = null; + useResumeStore.getState().updateResumeData( + (draft) => { + entryId = addBuiltinSection(draft, type); + }, + { newStep: true }, + ); + + const editor = useEditorStore.getState(); + editor.markSectionAdded(type); + editor.setSectionOpen(type, true); + if (entryId) openEntry(type, entryId, { focus: true }); + else requestAnimationFrame(() => document.querySelector(`#sidebar-${type} [role="textbox"]`)?.focus()); +} + +function addCustom(type: CustomSectionType) { + let ids = { sectionId: "", entryId: "" }; + useResumeStore.getState().updateResumeData( + (draft) => { + ids = addCustomSection(draft, type, getSectionTitle(type)); + }, + { newStep: true }, + ); + openEntry(ids.sectionId, ids.entryId, { focus: true }); +} + +/** + * Add section: every section not in use, in two columns, plus Custom section, which asks for a type. The + * new section starts with one draft entry, open and focused. + */ +export function AddSectionMenu() { + const added = useEditorStore((state) => state.addedSections); + const unusedKey = useCurrentBuilderResumeSelector((resume) => + (["summary", ...BUILTIN_SECTION_TYPES] as const) + .filter((type) => !isSectionInUse(resume.data, type, new Set(added))) + .join(","), + ); + const unused = unusedKey ? (unusedKey.split(",") as ("summary" | SectionType)[]) : []; + + return ( + + + + Add section + + } + /> + + {unused.length > 0 && ( + + {unused.map((type) => ( + addSection(type)}> + + {getSectionTitle(type)} + + ))} + + )} + {unused.length > 0 && } + + + + Custom section + + + + + What goes in it? + + {CUSTOM_SECTION_TYPES.map((type) => ( + addCustom(type)}> + + {getSectionTitle(type)} + + ))} + + + + + + ); +} + +const SUGGESTED: ("summary" | SectionType)[] = ["experience", "education", "skills", "summary"]; + +/** A blank resume: the first sections to add, as + chips, and an offer to import instead. */ +export function StartSuggestions({ onImport }: { onImport: () => void }) { + return ( +
+

+ Start with the sections most resumes have. +

+
+ {SUGGESTED.map((type) => ( + + ))} +
+

+ + Have a resume already?{" "} + + +

+
+ ); +} diff --git a/apps/web/src/features/resume/editor/write/basics-card.tsx b/apps/web/src/features/resume/editor/write/basics-card.tsx new file mode 100644 index 000000000..d16083f72 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/basics-card.tsx @@ -0,0 +1,239 @@ +import type { Basics, CustomField } from "@reactive-resume/schema/resume/data"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@reactive-resume/ui/components/button"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { IconButton } from "@reactive-resume/ui/components/icon-button"; +import { Input } from "@reactive-resume/ui/components/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover"; +import { generateId, getInitials } from "@reactive-resume/utils/string"; +import { cn } from "@reactive-resume/utils/style"; +import { IconPicker } from "@/components/input/icon-picker"; +import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; +import { useEditorStore } from "../store"; +import { TextField, validateEmail, WebsiteField } from "./fields"; +import { PictureSettings } from "./picture-settings"; + +type TextKey = "name" | "headline" | "email" | "phone" | "location"; + +function useBasicsWriter() { + const updateResumeData = useUpdateResumeData(); + return (key: string, mutate: (basics: Basics) => void) => + updateResumeData((draft) => mutate(draft.basics), { coalesceKey: `basics.${key}` }); +} + +/** + * The Basics card: name and "headline · location" with an initials avatar, opening to the photo, contact + * fields and custom fields. The page header selects it. + */ +export function BasicsCard({ locked }: { locked: boolean }) { + const basics = useCurrentBuilderResumeSelector((resume) => resume.data.basics); + const open = useEditorStore((state) => state.basicsOpen); + const setOpen = useEditorStore((state) => state.setBasicsOpen); + const selected = useEditorStore((state) => state.selection?.kind === "header"); + const write = useBasicsWriter(); + + const text = (key: TextKey) => ({ + value: basics[key], + onChange: (value: string) => + write(key, (target) => { + target[key] = value; + }), + }); + + return ( + + ); +} + +function PhotoRow({ locked }: { locked: boolean }) { + const picture = useCurrentBuilderResumeSelector((resume) => resume.data.picture); + const hasPhoto = Boolean(picture.url); + + return ( + + ); +} + +/** Extra contact details (icon, text and an optional link), as "Add field" under Website. */ +function CustomFields({ fields }: { fields: CustomField[] }) { + const updateResumeData = useUpdateResumeData(); + + const edit = (key: string, mutate: (list: CustomField[]) => void) => + updateResumeData((draft) => mutate(draft.basics.customFields), { coalesceKey: `basics.customFields.${key}` }); + + return ( +
+ {fields.map((field, index) => ( +
+ + edit(`${field.id}.icon`, (list) => { + const target = list[index]; + if (target) target.icon = icon; + }) + } + /> + + edit(`${field.id}.text`, (list) => { + const target = list[index]; + if (target) target.text = event.target.value; + }) + } + /> + + + } + /> + + + edit(`${field.id}.link`, (list) => { + const target = list[index]; + if (target) target.link = event.target.value; + }) + } + /> + + + edit(`${field.id}.move`, (list) => list.splice(index - 1, 0, ...list.splice(index, 1)))} + /> + edit(`${field.id}.remove`, (list) => list.splice(index, 1))} + /> +
+ ))} + +
+ ); +} diff --git a/apps/web/src/features/resume/editor/write/dates-field.test.tsx b/apps/web/src/features/resume/editor/write/dates-field.test.tsx new file mode 100644 index 000000000..3d1703658 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/dates-field.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment happy-dom +import type { ResumeDates } from "@reactive-resume/schema/resume/dates"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { DatesField, readTypedDate } from "./dates-field"; + +beforeAll(() => { + i18n.loadAndActivate({ locale: "en-US", messages: {} }); +}); + +describe("readTypedDate", () => { + it.each([ + ["Mar 2022", "2022-03"], + ["03/2022", "2022-03"], + ["2022-03", "2022-03"], + ["2022", "2022"], + ["", null], + ["soon", undefined], + ])("reads %j as %j", (text, expected) => { + expect(readTypedDate(text, "en-US")).toBe(expected); + }); +}); + +describe("DatesField", () => { + const renderField = (dates: ResumeDates, onChange = vi.fn()) => { + render( + + + , + ); + return onChange; + }; + + it("saves a typed date and clears the review note", () => { + const onChange = renderField({ start: "2016-06", end: "2018", present: false, raw: "Summer 2016 - 2018" }); + expect(screen.getByText(/We read "Summer 2016 - 2018"/)).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "Start" }), { target: { value: "Jul 2016" } }); + + expect(onChange).toHaveBeenLastCalledWith({ start: "2016-07", end: "2018", present: false }); + }); + + it("turns the end into Present and back", () => { + const onChange = renderField({ start: "2020", end: "2022", present: false }); + fireEvent.click(screen.getByRole("switch")); + expect(onChange).toHaveBeenLastCalledWith({ start: "2020", end: null, present: true }); + }); + + it("says how to fix a date it can't read, after blur", () => { + renderField({ start: null, end: null, present: false }); + const start = screen.getByRole("textbox", { name: "Start" }); + fireEvent.change(start, { target: { value: "someday" } }); + expect(screen.queryByText(/Use a month and year/)).not.toBeInTheDocument(); + fireEvent.blur(start); + expect(screen.getByText(/Use a month and year/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/resume/editor/write/dates-field.tsx b/apps/web/src/features/resume/editor/write/dates-field.tsx new file mode 100644 index 000000000..1cdbe3e15 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/dates-field.tsx @@ -0,0 +1,173 @@ +import type { DateFormat, ResumeDates, YearMonth } from "@reactive-resume/schema/resume/dates"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useId, useState } from "react"; +import { formatYearMonth, getPresentLabel, toYearMonth } from "@reactive-resume/schema/resume/dates"; +import { readSingleDate } from "@reactive-resume/schema/resume/period"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { inputBaseClassName } from "@reactive-resume/ui/components/input"; +import { Switch } from "@reactive-resume/ui/components/switch"; +import { cn } from "@reactive-resume/utils/style"; + +type DateSettings = { locale: string; format?: DateFormat | undefined }; + +/** What someone typed as a date: a year-month, null when cleared, or undefined when it can't be read. */ +export function readTypedDate(text: string, locale: string): YearMonth | null | undefined { + const value = text.trim(); + if (!value) return null; + const reading = readSingleDate(value, locale); + return reading ? toYearMonth(reading.endpoint) : undefined; +} + +const isBefore = (end: YearMonth, start: YearMonth) => end.localeCompare(start) < 0 && !start.startsWith(end); + +type MonthYearInputProps = DateSettings & { + label: string; + value: YearMonth | null; + disabled?: boolean; + /** Shown instead of the value, e.g. "Present" while the entry is ongoing. */ + placeholderValue?: string; + onCommit: (value: YearMonth | null) => void; + onInvalid: (invalid: boolean) => void; +}; + +/** + * One date, typed the way people write it ("Mar 2022", "03/2022", "2022-03" or just "2022"). A readable value + * saves as you type; on blur the field shows it in the resume's date format. + */ +function MonthYearInput({ + label, + value, + locale, + format, + disabled, + placeholderValue, + onCommit, + onInvalid, +}: MonthYearInputProps) { + const [typed, setTyped] = useState(null); + const formatted = value ? formatYearMonth(value, { locale, format }) : ""; + + return ( +
+ + { + setTyped(event.target.value); + const next = readTypedDate(event.target.value, locale); + if (next !== undefined && next !== value) onCommit(next); + }} + onBlur={() => { + if (typed === null) return; + const next = readTypedDate(typed, locale); + onInvalid(next === undefined); + if (next !== undefined) setTyped(null); + }} + /> +
+ ); +} + +type DatesFieldProps = DateSettings & { + dates: ResumeDates; + /** Awards, certifications and publications have one date. */ + single?: boolean; + onChange: (dates: ResumeDates) => void; + className?: string; +}; + +/** + * Structured dates: start – end with a Present switch, or a single date. Editing any part clears the + * "needs a look" note left by text that couldn't be read exactly. + */ +export function DatesField({ dates, single = false, locale, format, onChange, className }: DatesFieldProps) { + const id = useId(); + const [invalid, setInvalid] = useState>({ start: false, end: false }); + // Any edit replaces the dates without `raw`, which clears the review note. + const current: ResumeDates = { start: dates.start, end: dates.end, present: dates.present }; + const reversed = !single && !dates.present && dates.start && dates.end && isBefore(dates.end, dates.start); + const hasError = invalid.start || invalid.end; + + const setInvalidFor = (part: "start" | "end") => (value: boolean) => + setInvalid((state) => (state[part] === value ? state : { ...state, [part]: value })); + + return ( +
+ + {single ? Date : Dates} + + +
+ onChange({ ...current, start })} + onInvalid={setInvalidFor("start")} + /> + + {!single && ( + <> + + onChange({ ...current, end })} + onInvalid={setInvalidFor("end")} + /> + {/* biome-ignore lint/a11y/noLabelWithoutControl: Base UI's Switch is the control; wrapping it in a label is its documented pattern. */} + + + )} +
+ +
+ {hasError ? ( +

+ + Use a month and year, like Mar 2022, or just a year. +

+ ) : dates.raw !== undefined ? ( +

+ + {dates.start ? ( + We read "{dates.raw}". Pick a month so it sorts and prints consistently. + ) : ( + We couldn't read "{dates.raw}". Enter the dates so they sort and print consistently. + )} +

+ ) : reversed ? ( +

+ + The end is before the start. +

+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/features/resume/editor/write/entries.tsx b/apps/web/src/features/resume/editor/write/entries.tsx new file mode 100644 index 000000000..b8fb8ca1e --- /dev/null +++ b/apps/web/src/features/resume/editor/write/entries.tsx @@ -0,0 +1,452 @@ +import type { CustomSectionType, RoleItem, Website } from "@reactive-resume/schema/resume/data"; +import type { DateFormat, ResumeDates } from "@reactive-resume/schema/resume/dates"; +import type { ReactNode } from "react"; +import type { EntryWriter } from "./fields"; +import type { Entry } from "./model"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { EMPTY_RESUME_DATES } from "@reactive-resume/schema/resume/dates"; +import { Button } from "@reactive-resume/ui/components/button"; +import { FormControl, FormDescription, FormItem, FormLabel } from "@reactive-resume/ui/components/form"; +import { IconButton } from "@reactive-resume/ui/components/icon-button"; +import { PopoverTrigger } from "@reactive-resume/ui/components/popover"; +import { Slider } from "@reactive-resume/ui/components/slider"; +import { generateId } from "@reactive-resume/utils/string"; +import { ChipInput } from "@/components/input/chip-input"; +import { ColorPicker } from "@/components/input/color-picker"; +import { IconPicker } from "@/components/input/icon-picker"; +import { DatesField } from "./dates-field"; +import { MoreOptions, TextField, WebsiteField } from "./fields"; +import { RichTextEditor } from "./rich-text-editor"; + +export type PageSettings = { locale: string; dateFormat?: DateFormat | undefined }; + +type FieldSetProps = { entry: Entry; write: EntryWriter; page: PageSettings }; + +type Values = Record & { + website?: Website & { inlineLink?: boolean }; + dates?: ResumeDates; + keywords?: string[]; + roles?: RoleItem[]; + level?: number; +}; + +const valuesOf = (entry: Entry) => entry as unknown as Values; +const str = (value: unknown) => (typeof value === "string" ? value : ""); + +/** A text field bound to one entry field. */ +function Text({ + entry, + write, + field, + label, + wide, + autoFocus, +}: FieldSetProps & { field: string; label: ReactNode; wide?: boolean; autoFocus?: boolean }) { + return ( + + write(field, (target) => { + target[field] = value; + }) + } + /> + ); +} + +function Dates({ entry, write, page, single }: FieldSetProps & { single?: boolean }) { + return ( + + write("dates", (target) => { + target.dates = dates; + }) + } + /> + ); +} + +function Link({ entry, write }: FieldSetProps) { + const website = valuesOf(entry).website ?? { url: "", label: "", inlineLink: false }; + return ( + + write("website", (target) => { + target.website = value; + }) + } + /> + ); +} + +function Description({ + entry, + write, + field = "description", + label, +}: FieldSetProps & { field?: string; label?: string }) { + return ( +
+ {label ?? t`Description`} + + write(field, (target) => { + target[field] = html; + }) + } + /> +
+ ); +} + +function Keywords({ entry, write, label }: FieldSetProps & { label: ReactNode }) { + return ( + + {label} + + write("keywords", (target) => { + target.keywords = keywords; + }) + } + /> + } + /> + + ); +} + +function Level({ entry, write }: FieldSetProps) { + const level = Number(valuesOf(entry).level ?? 0); + return ( + + + Level + + + write("level", (target) => { + target.level = Array.isArray(value) ? value[0] : value; + }) + } + /> + } + /> + {level === 0 ? t`Hidden` : `${level} / 5`} + + ); +} + +/** The icon printed before the entry, and its colour (blank uses the template's). */ +function IconAndColor({ entry, write }: FieldSetProps) { + const values = valuesOf(entry); + return ( +
+ + write("icon", (target) => { + target.icon = icon; + }) + } + popoverProps={{ modal: true }} + /> + + write("iconColor", (target) => { + target.iconColor = color; + }) + } + trigger={ + + + Icon colour + + } + /> +
+ ); +} + +function Roles({ entry, write, page }: FieldSetProps) { + const roles = valuesOf(entry).roles ?? []; + + const writeRoles = (key: string, mutate: (roles: RoleItem[]) => void) => + write(`roles:${key}`, (target) => { + mutate(target.roles as RoleItem[]); + }); + + return ( +
+ {roles.map((role, index) => ( +
+
+ + Role {index + 1} + +
+ + writeRoles(`move-${role.id}`, (list) => list.splice(index - 1, 0, ...list.splice(index, 1))) + } + /> + + writeRoles(`move-${role.id}`, (list) => list.splice(index + 1, 0, ...list.splice(index, 1))) + } + /> + writeRoles(`remove-${role.id}`, (list) => list.splice(index, 1))} + /> +
+
+ Position} + value={role.position} + wide + onChange={(value) => + writeRoles(`${role.id}:position`, (list) => { + const target = list[index]; + if (target) target.position = value; + }) + } + /> + + writeRoles(`${role.id}:dates`, (list) => { + const target = list[index]; + if (target) target.dates = dates; + }) + } + /> +
+ + Description + + + writeRoles(`${role.id}:description`, (list) => { + const target = list[index]; + if (target) target.description = html; + }) + } + /> +
+
+ ))} + + +
+ ); +} + +/** The fields each entry type edits, in a two-column grid. The first field takes focus on a new draft. */ +export function EntryFields({ + type, + autoFocus, + ...props +}: FieldSetProps & { type: CustomSectionType; autoFocus?: boolean }) { + const hasRoles = (valuesOf(props.entry).roles?.length ?? 0) > 0; + + switch (type) { + case "experience": + return ( + <> + Position} autoFocus={autoFocus} /> + Company} /> + Location} wide /> + + + {!hasRoles && } + + + ); + case "education": + return ( + <> + School} wide autoFocus={autoFocus} /> + Degree} /> + Area of study} /> + Grade} /> + Location} /> + + + + + ); + case "projects": + return ( + <> + Name} wide autoFocus={autoFocus} /> + + + + + ); + case "skills": + return ( + <> + Name} autoFocus={autoFocus} /> + Proficiency} /> + Keywords} /> + + + + + + ); + case "languages": + return ( + <> + Language} autoFocus={autoFocus} /> + Fluency} /> + + + + + ); + case "interests": + return ( + <> + Name} wide autoFocus={autoFocus} /> + Keywords} /> + + + + + ); + case "awards": + return ( + <> + Title} autoFocus={autoFocus} /> + Awarder} /> + + + + + ); + case "certifications": + return ( + <> + Title} autoFocus={autoFocus} /> + Issuer} /> + + + + + ); + case "publications": + return ( + <> + Title} autoFocus={autoFocus} /> + Publisher} /> + + + + + ); + case "volunteer": + return ( + <> + Organization} autoFocus={autoFocus} /> + Location} /> + + + + + ); + case "references": + return ( + <> + Name} autoFocus={autoFocus} /> + Position} /> + Phone} wide /> + + + + ); + case "profiles": + return ( + <> + Network} autoFocus={autoFocus} /> + Username} /> + + + + + + ); + case "summary": + return ; + case "cover-letter": + return ( + <> + + + + ); + } +} diff --git a/apps/web/src/features/resume/editor/write/entry-card.tsx b/apps/web/src/features/resume/editor/write/entry-card.tsx new file mode 100644 index 000000000..08d7ad320 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/entry-card.tsx @@ -0,0 +1,340 @@ +import type { MessageDescriptor } from "@lingui/core"; +import type { KeyboardEvent, ReactNode } from "react"; +import type { PageSettings } from "./entries"; +import type { Entry, WriteSection } from "./model"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { msg, t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { useEffect, useMemo } from "react"; +import { Badge } from "@reactive-resume/ui/components/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@reactive-resume/ui/components/dropdown-menu"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { IconButton } from "@reactive-resume/ui/components/icon-button"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; +import { cn } from "@reactive-resume/utils/style"; +import { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft"; +import { atsFindingItemElementId } from "@/libs/resume/ats"; +import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item"; +import { useEditorStore } from "../store"; +import { EntryFields } from "./entries"; +import { useEntry, useEntryWriter } from "./fields"; +import { createEntry, describeEntry, getEntries, getPrimaryField, isDraftEntry } from "./model"; +import { useSectionTitle } from "./section-row"; + +const DRAFT_HINTS: Record = { + company: msg`Appears on the page once it has a company.`, + school: msg`Appears on the page once it has a school.`, + name: msg`Appears on the page once it has a name.`, + language: msg`Appears on the page once it has a language.`, + title: msg`Appears on the page once it has a title.`, + organization: msg`Appears on the page once it has an organization.`, + network: msg`Appears on the page once it has a network.`, +}; + +type EntryCardProps = { + section: WriteSection; + entryId: string; + index: number; + count: number; + page: PageSettings; + locked: boolean; + /** ⌥↑ / ⌥↓ on the title. */ + onMove: (entryId: string, direction: "up" | "down") => void; +}; + +/** + * An entry in the outline. Collapsed: its title and "company · location · dates". Open (one at a time, the + * editor's selection): its fields, saved as you type. Deleting is immediate, with Undo in the toast. + */ +export function EntryCard({ section, entryId, index, count, page, locked, onMove }: EntryCardProps) { + const { i18n } = useLingui(); + const entry = useEntry(section.id, entryId); + const write = useEntryWriter(section.id, entryId); + const open = useEditorStore((state) => state.selection?.kind === "item" && state.selection.itemId === entryId); + const autoFocus = useEditorStore((state) => state.focusEntryId === entryId); + const select = useEditorStore((state) => state.select); + const sortable = useSortable({ id: entryId, disabled: locked }); + const isPhone = useBreakpoint() === "mobile"; + + // The first field took focus as it mounted; later openings shouldn't steal focus again. + useEffect(() => { + if (autoFocus) useEditorStore.getState().setFocusEntry(null); + }, [autoFocus]); + + if (!entry) return null; + + const { title, meta } = describeEntry(section.type, entry); + const draft = isDraftEntry(section.type, entry); + const primaryField = getPrimaryField(section.type); + const hint = draft && primaryField ? DRAFT_HINTS[primaryField] : undefined; + + const toggle = () => select(open ? null : { kind: "item", sectionId: section.id, itemId: entryId }); + const fields = ( + <> + {hint &&

{i18n._(hint)}

} + + + ); + + const onTitleKeyDown = (event: KeyboardEvent) => { + if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; + event.preventDefault(); + onMove(entryId, event.key === "ArrowUp" ? "up" : "down"); + }; + + return ( +
+
+ {!locked && ( + + )} + + + {open && !locked && ( + deleteEntry(section, entryId)} + /> + )} + {!locked && } +
+ + {open && !isPhone && ( +
+ {fields} +
+ )} + + {open && isPhone && ( + deleteEntry(section, entryId)} + onBack={() => select(null)} + > + {fields} + + )} +
+ ); +} + +type PhoneEntryScreenProps = { + section: WriteSection; + title: string; + locked: boolean; + onBack: () => void; + onDelete: () => void; + children: ReactNode; +}; + +/** Phones: an open entry pushes in full screen, with a back label naming its section and 44px fields. */ +function PhoneEntryScreen({ section, title, locked, onBack, onDelete, children }: PhoneEntryScreenProps) { + const sectionTitle = useSectionTitle(section); + + return ( +
+
+ + + {!locked && ( + + )} +
+
+ {children} +
+
+ ); +} + +function deleteEntry(section: WriteSection, entryId: string) { + const { updateResumeData, undo } = useResumeStore.getState(); + updateResumeData( + (draft) => { + const entries = getEntries(draft, section); + const index = entries.findIndex((entry) => entry.id === entryId); + if (index !== -1) entries.splice(index, 1); + }, + { newStep: true }, + ); + useEditorStore.getState().select(null); + toast.add({ description: t`Entry deleted`, actionProps: { children: t`Undo`, onClick: undo } }); +} + +type EntryMenuProps = { section: WriteSection; entry: Entry }; + +/** Hide from page, Duplicate, Move to… and Delete. */ +function EntryMenu({ section, entry }: EntryMenuProps) { + const resume = useCurrentResume(); + const updateResumeData = useUpdateResumeData(); + const customSectionId = section.kind === "custom" ? section.id : undefined; + const moveTargets = useMemo( + () => getCompatibleMoveTargets(resume.data, section.type, customSectionId), + [resume.data, section.type, customSectionId], + ); + const sourceTitle = useMemo( + () => getSourceSectionTitle(resume.data, section.type, customSectionId), + [resume.data, section.type, customSectionId], + ); + + const move = (target: Parameters[1]["target"]) => + updateResumeData((draft) => moveItem(draft, { itemId: entry.id, type: section.type, customSectionId, target }), { + newStep: true, + }); + + const duplicate = () => { + const copy = { ...structuredClone(entry), id: createEntry(section.type).id } as Entry; + updateResumeData( + (draft) => { + const entries = getEntries(draft, section); + entries.splice(entries.findIndex((item) => item.id === entry.id) + 1, 0, copy); + }, + { newStep: true }, + ); + useEditorStore.getState().select({ kind: "item", sectionId: section.id, itemId: copy.id }); + }; + + const toggleHidden = () => + updateResumeData( + (draft) => { + const target = getEntries(draft, section).find((item) => item.id === entry.id); + if (target) target.hidden = !target.hidden; + }, + { newStep: true }, + ); + + return ( + + } + /> + + + + {entry.hidden ? Show on page : Hide from page} + + + + Duplicate + + + + + Move to… + + + {moveTargets.map(({ pageIndex, sections }) => ( + + + + Page {pageIndex + 1} + + + {sections.map(({ sectionId, sectionTitle }) => ( + move({ type: "section", sectionId })}> + {sectionTitle} + + ))} + {sections.length > 0 && } + move({ type: "new-section", title: sourceTitle, pageIndex })}> + + New section + + + + ))} + + move({ type: "new-page", title: sourceTitle })}> + + New page + + + + + deleteEntry(section, entry.id)}> + + Delete + + + + ); +} diff --git a/apps/web/src/features/resume/editor/write/fields.tsx b/apps/web/src/features/resume/editor/write/fields.tsx new file mode 100644 index 000000000..ae9a1db6f --- /dev/null +++ b/apps/web/src/features/resume/editor/write/fields.tsx @@ -0,0 +1,168 @@ +import type { Website } from "@reactive-resume/schema/resume/data"; +import type { ComponentProps, ReactNode } from "react"; +import type { Entry } from "./model"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useCallback, useState } from "react"; +import { Checkbox } from "@reactive-resume/ui/components/checkbox"; +import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; +import { Input } from "@reactive-resume/ui/components/input"; +import { cn } from "@reactive-resume/utils/style"; +import { URLInput } from "@/components/input/url-input"; +import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; +import { findEntry } from "./model"; + +/** One entry of a section, re-rendering only when that entry changes; null once it's deleted. */ +export const useEntry = (sectionId: string, entryId: string) => + useCurrentBuilderResumeSelector((resume) => findEntry(resume.data, sectionId, entryId) ?? null); + +export type EntryWriter = (key: string, mutate: (entry: Record) => void) => void; + +/** + * Writes one field of an entry. The key names the field, so typing in one field folds into one undo step + * while a different field starts a new one. + */ +export function useEntryWriter(sectionId: string, entryId: string): EntryWriter { + const updateResumeData = useUpdateResumeData(); + + return useCallback( + (key, mutate) => { + updateResumeData( + (draft) => { + const entry = findEntry(draft, sectionId, entryId); + if (entry) mutate(entry as Entry & Record); + }, + { coalesceKey: `${entryId}:${key}` }, + ); + }, + [updateResumeData, sectionId, entryId], + ); +} + +type Validator = (value: string) => string | undefined; + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + +/** Says how to fix an email address rather than only that it's wrong. */ +export const validateEmail: Validator = (value) => { + const email = value.trim(); + if (!email || EMAIL.test(email)) return undefined; + const [name, domain] = email.split("@"); + if (!name || domain === undefined) return t`Add an @ and the domain, like name@example.com`; + if (!domain) return t`Add the domain after the @, like ${name}@example.com`; + return t`Add the domain ending, like ${domain}.com`; +}; + +/** Accepts web addresses with or without https://, as the page links them either way. */ +const validateUrl: Validator = (value) => { + const url = value.trim(); + if (!url) return undefined; + try { + const parsed = new URL(/^[a-z]+:\/\//i.test(url) ? url : `https://${url}`); + if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname.includes(".")) throw new Error(); + return undefined; + } catch { + return t`Enter a web address, like example.com`; + } +}; + +type TextFieldProps = Omit, "value" | "onChange"> & { + label: ReactNode; + value: string; + onChange: (value: string) => void; + /** Checked after the field is first left, then as you type. */ + validate?: Validator; + hint?: ReactNode; + wide?: boolean; +}; + +/** A labelled text field. Errors appear after the first blur and say how to fix them. */ +export function TextField({ + label, + value, + onChange, + validate, + hint, + wide, + className, + onBlur, + ...props +}: TextFieldProps) { + const [touched, setTouched] = useState(false); + const error = touched ? validate?.(value) : undefined; + + return ( + + {label} + onChange(event.target.value)} + onBlur={(event) => { + setTouched(true); + onBlur?.(event); + }} + className={className} + {...props} + /> + } + /> + + {hint && !error && {hint}} + + ); +} + +type WebsiteFieldProps = { + label?: ReactNode; + value: Website & { inlineLink?: boolean }; + onChange: (value: Website & { inlineLink?: boolean }) => void; + /** Entries can print the link on their title instead of on its own line. */ + allowInlineLink?: boolean; +}; + +export function WebsiteField({ label, value, onChange, allowInlineLink = true }: WebsiteFieldProps) { + const [touched, setTouched] = useState(false); + const error = touched ? validateUrl(value.url) : undefined; + const inline = value.inlineLink ?? false; + + return ( +
+ + {label ?? Link} + setTouched(true)} /> + } + /> + + + + {allowInlineLink && ( + // biome-ignore lint/a11y/noLabelWithoutControl: Base UI's Checkbox is the control; wrapping it in a label is its documented pattern. + + )} +
+ ); +} + +type MoreOptionsProps = { children: ReactNode }; + +/** Secondary fields (level, icon, colour) stay out of the way until asked for. */ +export function MoreOptions({ children }: MoreOptionsProps) { + return ( +
+ + + More options + +
{children}
+
+ ); +} diff --git a/apps/web/src/features/resume/editor/write/model.test.ts b/apps/web/src/features/resume/editor/write/model.test.ts new file mode 100644 index 000000000..fbb75414b --- /dev/null +++ b/apps/web/src/features/resume/editor/write/model.test.ts @@ -0,0 +1,164 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { describe, expect, it } from "vitest"; +import { produce } from "immer"; +import { customSectionItemDefinitionByType } from "@reactive-resume/schema/resume/data"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { + addBuiltinSection, + addCustomSection, + CUSTOM_SECTION_TYPES, + countEntriesToCheck, + createEntry, + describeEntry, + getOutlineRows, + isDraftEntry, + isSectionInUse, + moveSection, + resolveSection, +} from "./model"; + +const resume = (edit?: (data: ResumeData) => void) => { + const data = structuredClone(defaultResumeData); + data.metadata.layout.pages = [ + { fullWidth: false, main: ["summary", "experience", "education"], sidebar: ["skills"] }, + ]; + edit?.(data); + return data; +}; + +const withEntries = (data: ResumeData, ...types: ("experience" | "education" | "skills")[]) => { + for (const type of types) { + const entry = createEntry(type) as { name?: string; company?: string; school?: string }; + if (type === "experience") entry.company = "Lumen"; + if (type === "education") entry.school = "UdK"; + if (type === "skills") entry.name = "Figma"; + (data.sections[type].items as unknown[]).push(entry); + } +}; + +describe("createEntry", () => { + it.each(CUSTOM_SECTION_TYPES)("makes a %s entry the schema accepts", (type) => { + const entry = createEntry(type); + expect(customSectionItemDefinitionByType[type].schema.safeParse(entry).success).toBe(true); + }); +}); + +describe("drafts", () => { + it("treats an entry without its primary field as a draft", () => { + expect(isDraftEntry("experience", createEntry("experience"))).toBe(true); + expect(isDraftEntry("experience", { ...createEntry("experience"), company: "Lumen" } as never)).toBe(false); + expect(isDraftEntry("summary", createEntry("summary"))).toBe(false); + }); +}); + +describe("describeEntry", () => { + it("titles experience by position, then company · location · dates", () => { + const entry = { + ...createEntry("experience"), + position: "Designer", + company: "Lumen", + location: "Berlin", + period: "Mar 2022 – Present", + }; + expect(describeEntry("experience", entry as never)).toEqual({ + title: "Designer", + meta: "Lumen · Berlin · Mar 2022 – Present", + }); + }); +}); + +describe("outline", () => { + it("lists sections in use in print order: main, then sidebar", () => { + const data = resume((draft) => withEntries(draft, "skills", "education")); + expect(getOutlineRows(data)).toEqual([ + { id: "education", page: 0, column: "main" }, + { id: "skills", page: 0, column: "sidebar" }, + ]); + }); + + it("keeps just-added and custom sections, and lists unplaced sections last", () => { + const data = resume((draft) => { + withEntries(draft, "experience"); + draft.sections.projects.items = [createEntry("projects") as never]; + draft.customSections = [{ ...structuredClone(draft.sections.skills), id: "custom-1", type: "skills" } as never]; + }); + + expect(getOutlineRows(data, new Set(["summary"])).map((row) => row.id)).toEqual([ + "summary", + "experience", + "projects", + "custom-1", + ]); + expect(isSectionInUse(data, "summary")).toBe(false); + }); + + it("moves a section before a row when moving up and after it when moving down", () => { + const data = resume((draft) => withEntries(draft, "experience", "education", "skills")); + const up = produce(data, (draft) => + moveSection(draft, "education", { id: "experience", page: 0, column: "main" }, "up"), + ); + expect(up.metadata.layout.pages[0]?.main).toEqual(["summary", "education", "experience"]); + + const down = produce(data, (draft) => + moveSection(draft, "experience", { id: "education", page: 0, column: "main" }, "down"), + ); + expect(down.metadata.layout.pages[0]?.main).toEqual(["summary", "education", "experience"]); + }); + + it("changes column when a section moves across the sidebar divider", () => { + const data = resume((draft) => withEntries(draft, "experience", "skills")); + const moved = produce(data, (draft) => + moveSection(draft, "skills", { id: "experience", page: 0, column: "main" }, "up"), + ); + expect(moved.metadata.layout.pages[0]).toMatchObject({ + main: ["summary", "skills", "experience", "education"], + sidebar: [], + }); + }); +}); + +describe("add section", () => { + it("shows and places a built-in section with one draft entry", () => { + const data = resume((draft) => { + draft.sections.projects.hidden = true; + }); + let entryId: string | null = null; + const next = produce(data, (draft) => { + entryId = addBuiltinSection(draft, "projects"); + }); + + expect(next.sections.projects.hidden).toBe(false); + expect(next.metadata.layout.pages[0]?.main).toContain("projects"); + expect(next.sections.projects.items).toHaveLength(1); + expect(next.sections.projects.items[0]?.id).toBe(entryId); + expect(isDraftEntry("projects", next.sections.projects.items[0] as never)).toBe(true); + }); + + it("creates a custom section of a type, named and placed, with one draft entry", () => { + let ids = { sectionId: "", entryId: "" }; + const next = produce(resume(), (draft) => { + ids = addCustomSection(draft, "awards", "Honours"); + }); + + expect(resolveSection(next, ids.sectionId)).toEqual({ id: ids.sectionId, kind: "custom", type: "awards" }); + expect(next.customSections[0]).toMatchObject({ title: "Honours", items: [{ id: ids.entryId }] }); + expect(next.metadata.layout.pages[0]?.main.at(-1)).toBe(ids.sectionId); + }); +}); + +describe("countEntriesToCheck", () => { + it("counts entries whose dates, or whose roles' dates, still carry text to review", () => { + const raw = { start: null, end: null, present: false, raw: "Summer 2016" }; + const exact = { start: "2016", end: null, present: false }; + const entries = [ + { ...createEntry("experience"), dates: raw }, + { + ...createEntry("experience"), + dates: exact, + roles: [{ id: "r", position: "", period: "", description: "", dates: raw }], + }, + { ...createEntry("experience"), dates: exact }, + ]; + expect(countEntriesToCheck(entries as never)).toBe(2); + }); +}); diff --git a/apps/web/src/features/resume/editor/write/model.ts b/apps/web/src/features/resume/editor/write/model.ts new file mode 100644 index 000000000..2bca81f61 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/model.ts @@ -0,0 +1,334 @@ +import type { + CustomSection, + CustomSectionItem, + CustomSectionType, + ResumeData, + SectionType, +} from "@reactive-resume/schema/resume/data"; +import type { WritableDraft } from "immer"; +import { EMPTY_RESUME_DATES } from "@reactive-resume/schema/resume/dates"; +import { generateId, stripHtml } from "@reactive-resume/utils/string"; + +type Data = ResumeData | WritableDraft; + +/** One entry of any section. Each section type narrows it in its own field set. */ +export type Entry = CustomSectionItem; + +/** A section of the outline: the summary, a built-in section (keyed by type) or a custom section (by id). */ +export type WriteSection = { id: string; kind: "summary" | "builtin" | "custom"; type: CustomSectionType }; + +export const BUILTIN_SECTION_TYPES = [ + "profiles", + "experience", + "education", + "projects", + "skills", + "languages", + "interests", + "awards", + "certifications", + "publications", + "volunteer", + "references", +] as const satisfies readonly SectionType[]; + +export const CUSTOM_SECTION_TYPES = [ + "experience", + "education", + "projects", + "skills", + "languages", + "interests", + "awards", + "certifications", + "publications", + "volunteer", + "references", + "profiles", + "summary", + "cover-letter", +] as const satisfies readonly CustomSectionType[]; + +const isBuiltinSectionType = (id: string): id is SectionType => + (BUILTIN_SECTION_TYPES as readonly string[]).includes(id); + +export const getSectionKind = (id: string): WriteSection["kind"] => + id === "summary" ? "summary" : isBuiltinSectionType(id) ? "builtin" : "custom"; + +/** The type of a section id: its own key for the summary and built-ins, the stored type for custom ones. */ +export function getSectionType(data: Data, id: string): CustomSectionType | undefined { + if (id === "summary") return "summary"; + if (isBuiltinSectionType(id)) return id; + return data.customSections.find((section) => section.id === id)?.type; +} + +export function resolveSection(data: Data, id: string): WriteSection | null { + const type = getSectionType(data, id); + return type ? { id, kind: getSectionKind(id), type } : null; +} + +/** The entries of a section, from saved data or inside an immer draft. The summary has none. */ +export function getEntries(data: Data, section: WriteSection): Entry[] { + if (section.kind === "builtin") return data.sections[section.id as SectionType].items as Entry[]; + if (section.kind === "custom") + return (data.customSections.find((custom) => custom.id === section.id)?.items ?? []) as Entry[]; + return []; +} + +type SectionObject = ResumeData["summary"] | ResumeData["sections"][SectionType] | CustomSection; + +/** The section's own settings (title, icon, columns, hidden…), which every kind of section shares. */ +export function getSectionObject(data: Data, section: WriteSection): SectionObject | undefined { + if (section.kind === "summary") return data.summary; + if (section.kind === "builtin") return data.sections[section.id as SectionType]; + return data.customSections.find((custom) => custom.id === section.id); +} + +export function findEntry(data: Data, sectionId: string, entryId: string): Entry | undefined { + const section = resolveSection(data, sectionId); + return section ? getEntries(data, section).find((entry) => entry.id === entryId) : undefined; +} + +/** The field an entry needs before it prints, matching the PDF renderer's filter. Others always print. */ +const PRIMARY_FIELD: Partial> = { + profiles: "network", + experience: "company", + education: "school", + projects: "name", + skills: "name", + languages: "language", + interests: "name", + awards: "title", + certifications: "title", + publications: "title", + volunteer: "organization", + references: "name", +}; + +export const getPrimaryField = (type: CustomSectionType) => PRIMARY_FIELD[type]; + +/** An entry without its primary field is a draft: it's kept, but not printed. */ +export function isDraftEntry(type: CustomSectionType, entry: Entry): boolean { + const field = PRIMARY_FIELD[type]; + if (!field) return false; + const value = (entry as Record)[field]; + return typeof value !== "string" || !value.trim(); +} + +const website = () => ({ url: "", label: "", inlineLink: false }); +const dates = () => ({ ...EMPTY_RESUME_DATES }); + +/** A new, empty entry of a type: the starting point for "+ Add" and for Add section's first draft. */ +export function createEntry(type: CustomSectionType): Entry { + const base = { id: generateId(), hidden: false }; + const fields: Record> = { + profiles: { icon: "", iconColor: "", network: "", username: "", website: website() }, + experience: { + company: "", + position: "", + location: "", + period: "", + dates: dates(), + website: website(), + description: "", + roles: [], + }, + education: { + school: "", + degree: "", + area: "", + grade: "", + location: "", + period: "", + dates: dates(), + website: website(), + description: "", + }, + projects: { name: "", period: "", dates: dates(), website: website(), description: "" }, + skills: { icon: "", iconColor: "", name: "", proficiency: "", level: 0, keywords: [] }, + languages: { language: "", fluency: "", level: 0 }, + interests: { icon: "", iconColor: "", name: "", keywords: [] }, + awards: { title: "", awarder: "", date: "", dates: dates(), website: website(), description: "" }, + certifications: { title: "", issuer: "", date: "", dates: dates(), website: website(), description: "" }, + publications: { title: "", publisher: "", date: "", dates: dates(), website: website(), description: "" }, + volunteer: { organization: "", location: "", period: "", dates: dates(), website: website(), description: "" }, + references: { name: "", position: "", website: website(), phone: "", description: "" }, + summary: { content: "" }, + "cover-letter": { recipient: "", content: "" }, + }; + return { ...base, ...fields[type] } as Entry; +} + +const text = (value: unknown) => (typeof value === "string" ? value.trim() : ""); +const join = (...parts: unknown[]) => parts.map(text).filter(Boolean).join(" · "); + +/** + * How an entry reads in the outline: a title and a line of details ("company · location · dates"). Dates come + * from the legacy text, which is kept formatted from the structured dates. + */ +export function describeEntry(type: CustomSectionType, entry: Entry): { title: string; meta: string } { + const e = entry as Record & { website?: { label?: string }; keywords?: string[] }; + const keywords = (e.keywords ?? []).join(", "); + + switch (type) { + case "experience": { + const position = text(e.position); + return { title: position || text(e.company), meta: join(position ? e.company : "", e.location, e.period) }; + } + case "education": + return { + title: text(e.school), + meta: join([text(e.degree), text(e.area)].filter(Boolean).join(", "), e.location, e.period), + }; + case "projects": + return { title: text(e.name), meta: join(e.period, e.website?.label) }; + case "skills": + return { title: text(e.name), meta: join(e.proficiency, keywords) }; + case "languages": + return { title: text(e.language), meta: join(e.fluency) }; + case "interests": + return { title: text(e.name), meta: keywords }; + case "awards": + return { title: text(e.title), meta: join(e.awarder, e.date) }; + case "certifications": + return { title: text(e.title), meta: join(e.issuer, e.date) }; + case "publications": + return { title: text(e.title), meta: join(e.publisher, e.date) }; + case "volunteer": + return { title: text(e.organization), meta: join(e.location, e.period) }; + case "references": + return { title: text(e.name), meta: join(e.position) }; + case "profiles": + return { title: text(e.network), meta: join(e.username) }; + case "summary": + case "cover-letter": { + const body = stripHtml(String(e.content ?? "")).trim(); + return { title: body.length > 80 ? `${body.slice(0, 80)}…` : body, meta: "" }; + } + } +} + +/** Entries (and roles) whose dates came from text that couldn't be read exactly and still ask for a look. */ +export function countEntriesToCheck(entries: readonly Entry[]): number { + let count = 0; + type WithDates = { dates?: { raw?: string }; roles?: readonly { dates?: { raw?: string } }[] }; + for (const entry of entries as readonly WithDates[]) { + if (entry.dates?.raw !== undefined || entry.roles?.some((role) => role.dates?.raw !== undefined)) count += 1; + } + return count; +} + +/** Sections that have something in them. The rest wait in Add section. Custom sections always count. */ +export function isSectionInUse(data: Data, id: string, recentlyAdded: ReadonlySet = new Set()): boolean { + if (recentlyAdded.has(id)) return true; + const section = resolveSection(data, id); + if (!section) return false; + if (section.kind === "summary") return Boolean(stripHtml(data.summary.content).trim()); + if (section.kind === "custom") return true; + return getEntries(data, section).length > 0; +} + +type LayoutColumn = "main" | "sidebar"; + +/** A row of the outline, in print order, with where it sits in the layout. */ +export type OutlineRow = { id: string; page: number; column: LayoutColumn }; + +/** + * The outline lists the sections in use in print order: page by page, the main column and then the sidebar. + * Sections in use that no page places (rare) are listed last, as the last page's main column. + */ +export function getOutlineRows(data: Data, recentlyAdded?: ReadonlySet): OutlineRow[] { + const rows: OutlineRow[] = []; + const placed = new Set(); + + data.metadata.layout.pages.forEach((page, pageIndex) => { + for (const column of ["main", "sidebar"] as const) { + for (const id of page[column]) { + placed.add(id); + if (isSectionInUse(data, id, recentlyAdded)) rows.push({ id, page: pageIndex, column }); + } + } + }); + + const lastPage = Math.max(0, data.metadata.layout.pages.length - 1); + const candidates = ["summary", ...BUILTIN_SECTION_TYPES, ...data.customSections.map((section) => section.id)]; + for (const id of candidates) { + if (!placed.has(id) && isSectionInUse(data, id, recentlyAdded)) rows.push({ id, page: lastPage, column: "main" }); + } + + return rows; +} + +function removeFromLayout(draft: WritableDraft, id: string) { + for (const page of draft.metadata.layout.pages) { + page.main = page.main.filter((section) => section !== id); + page.sidebar = page.sidebar.filter((section) => section !== id); + } +} + +/** + * Moves a section to where another row sits: before it when moving up, after it when moving down. Rows in a + * different page or column take the section with them, so dragging across the sidebar divider changes columns. + */ +export function moveSection( + draft: WritableDraft, + id: string, + target: OutlineRow, + direction: "up" | "down", +) { + if (id === target.id) return; + removeFromLayout(draft, id); + + const page = draft.metadata.layout.pages[target.page]; + if (!page) return; + const column = page[target.column]; + const index = column.indexOf(target.id); + const insertAt = index === -1 ? column.length : direction === "down" ? index + 1 : index; + column.splice(insertAt, 0, id); +} + +/** Puts a section on the last page's main column unless a page already places it. */ +function ensurePlaced(draft: WritableDraft, id: string) { + const pages = draft.metadata.layout.pages; + if (pages.some((page) => page.main.includes(id) || page.sidebar.includes(id))) return; + if (pages.length === 0) pages.push({ fullWidth: false, main: [], sidebar: [] }); + pages[pages.length - 1]?.main.push(id); +} + +/** + * Add section: shows the section, places it if needed and gives it one draft entry to fill in. Returns the + * new entry's id (none for the summary, which is one rich text). + */ +export function addBuiltinSection(draft: WritableDraft, type: "summary" | SectionType): string | null { + ensurePlaced(draft, type); + if (type === "summary") { + draft.summary.hidden = false; + return null; + } + + const section = draft.sections[type]; + section.hidden = false; + const entry = createEntry(type); + (section.items as Entry[]).push(entry); + return entry.id; +} + +/** Add section → Custom section: a new section of a type, named after it, with one draft entry. */ +export function addCustomSection(draft: WritableDraft, type: CustomSectionType, title: string) { + const entry = createEntry(type); + const section = { + id: generateId(), + title, + type, + icon: "", + columns: 1, + hidden: false, + showHeading: true, + keepTogether: false, + startOnNewPage: false, + items: [entry], + } as CustomSection; + draft.customSections.push(section); + ensurePlaced(draft, section.id); + return { sectionId: section.id, entryId: entry.id }; +} diff --git a/apps/web/src/features/resume/editor/write/outline.tsx b/apps/web/src/features/resume/editor/write/outline.tsx new file mode 100644 index 000000000..1961d5b42 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/outline.tsx @@ -0,0 +1,216 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import type { MessageDescriptor } from "@lingui/core"; +import type { CustomSectionType } from "@reactive-resume/schema/resume/data"; +import type { PageSettings } from "./entries"; +import type { OutlineRow, WriteSection } from "./model"; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { msg } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Fragment, useMemo } from "react"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; +import { useEditorStore } from "../store"; +import { addEntryTo } from "./actions"; +import { EntryCard } from "./entry-card"; +import { getEntries, getOutlineRows, getSectionKind, getSectionType, moveSection } from "./model"; +import { SectionRow } from "./section-row"; +import { SummaryEditor } from "./summary-editor"; + +const ADD_LABELS: Record = { + summary: msg`Add text`, + experience: msg`Add experience`, + education: msg`Add education`, + projects: msg`Add project`, + skills: msg`Add skill`, + languages: msg`Add language`, + interests: msg`Add interest`, + awards: msg`Add award`, + certifications: msg`Add certification`, + publications: msg`Add publication`, + volunteer: msg`Add volunteering`, + references: msg`Add reference`, + profiles: msg`Add profile`, + "cover-letter": msg`Add cover letter`, +}; + +const rowKey = (row: OutlineRow) => `${row.id}\u0000${row.page}\u0000${row.column}`; + +function parseRows(key: string): OutlineRow[] { + if (!key) return []; + return key.split("\u0001").map((part) => { + const [id = "", page = "0", column = "main"] = part.split("\u0000"); + return { id, page: Number(page), column: column as OutlineRow["column"] }; + }); +} + +function useSortSensors() { + return useSensors( + // A small distance keeps clicks on the handle from starting a drag. + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); +} + +type OutlineProps = { locked: boolean; page: PageSettings }; + +/** + * The outline: sections in use, in print order. Dragging a row (or ⌥↑ / ⌥↓ on its title) reorders the + * layout; a divider marks each new page and, on two-column pages, where the sidebar starts. + */ +export function Outline({ locked, page }: OutlineProps) { + const added = useEditorStore((state) => state.addedSections); + const key = useCurrentBuilderResumeSelector((resume) => + getOutlineRows(resume.data, new Set(added)).map(rowKey).join("\u0001"), + ); + const rows = useMemo(() => parseRows(key), [key]); + const multiPage = rows.some((row) => row.page > 0); + const updateResumeData = useUpdateResumeData(); + const sensors = useSortSensors(); + + const move = (id: string, target: OutlineRow | undefined, direction: "up" | "down") => { + if (!target) return; + updateResumeData((draft) => moveSection(draft, id, target, direction), { newStep: true }); + }; + + const onDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const from = rows.findIndex((row) => row.id === active.id); + const to = rows.findIndex((row) => row.id === over.id); + move(String(active.id), rows[to], from < to ? "down" : "up"); + }; + + return ( + + row.id)} strategy={verticalListSortingStrategy}> +
+ {rows.map((row, index) => { + const previous = rows[index - 1]; + const newPage = multiPage && row.page !== previous?.page; + const sidebarStarts = row.column === "sidebar" && (previous?.column !== "sidebar" || newPage); + + return ( + + {newPage && Page {row.page + 1}} />} + {sidebarStarts && Sidebar} subtle />} + move(row.id, rows[direction === "up" ? index - 1 : index + 1], direction)} + /> + + ); + })} +
+
+
+ ); +} + +function Divider({ label, subtle = false }: { label: React.ReactNode; subtle?: boolean }) { + return ( +
+ + {label} + + +
+ ); +} + +type OutlineSectionProps = { + sectionId: string; + locked: boolean; + page: PageSettings; + onMove: (direction: "up" | "down") => void; +}; + +function OutlineSection({ sectionId, locked, page, onMove }: OutlineSectionProps) { + const { i18n } = useLingui(); + // Select the type (a string) rather than a new section object, so the row renders only when it changes. + const type = useCurrentBuilderResumeSelector((resume) => getSectionType(resume.data, sectionId) ?? null); + const stableSection = useMemo( + () => (type ? { id: sectionId, kind: getSectionKind(sectionId), type } : null), + [sectionId, type], + ); + if (!stableSection) return null; + + return ( + + {stableSection.kind === "summary" ? ( + + ) : ( + <> + + {!locked && ( + + )} + + )} + + ); +} + +type EntryListProps = { section: WriteSection; locked: boolean; page: PageSettings }; + +/** Entries reorder like sections: drag the card's handle, or ⌥↑ / ⌥↓ on its title. */ +function EntryList({ section, locked, page }: EntryListProps) { + const ids = useCurrentBuilderResumeSelector((resume) => + getEntries(resume.data, section) + .map((entry) => entry.id) + .join("\u0000"), + ); + const entryIds = useMemo(() => (ids ? ids.split("\u0000") : []), [ids]); + const updateResumeData = useUpdateResumeData(); + const sensors = useSortSensors(); + + const move = (entryId: string, to: number) => { + if (to < 0 || to >= entryIds.length) return; + updateResumeData( + (draft) => { + const entries = getEntries(draft, section); + const from = entries.findIndex((entry) => entry.id === entryId); + const [entry] = entries.splice(from, 1); + if (entry) entries.splice(to, 0, entry); + }, + { newStep: true }, + ); + }; + + const onDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + move(String(active.id), entryIds.indexOf(String(over.id))); + }; + + return ( + + + {entryIds.map((entryId, index) => ( + move(id, direction === "up" ? index - 1 : index + 1)} + /> + ))} + + + ); +} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx b/apps/web/src/features/resume/editor/write/picture-settings.tsx similarity index 99% rename from apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx rename to apps/web/src/features/resume/editor/write/picture-settings.tsx index 1924e7e65..c93bf8d91 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx +++ b/apps/web/src/features/resume/editor/write/picture-settings.tsx @@ -42,15 +42,6 @@ import { useSyncFormValues } from "@/hooks/use-sync-form-values"; import { getReadableErrorMessage } from "@/libs/error-message"; import { orpc } from "@/libs/orpc/client"; import { useAppForm } from "@/libs/tanstack-form"; -import { SectionBase } from "../shared/section-base"; - -export function PictureSectionBuilder() { - return ( - - - - ); -} type PicturePreviewControlsProps = { fileInputRef: React.RefObject; @@ -502,7 +493,8 @@ type CropState = { imageSrc: string; }; -function PictureSectionForm() { +/** Every photo option: upload with crop, address, show or hide, delete, fit, size, rotation, shape, border and shadow. */ +export function PictureSettings() { const fileInputRef = useRef(null); const appOrigin = typeof window === "undefined" ? "" : window.location.origin; diff --git a/apps/web/src/features/resume/editor/write/reveal.ts b/apps/web/src/features/resume/editor/write/reveal.ts new file mode 100644 index 000000000..8cda39467 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/reveal.ts @@ -0,0 +1,45 @@ +import type { EditorSelection } from "../store"; +import { atsFindingItemElementId } from "@/libs/resume/ats"; +import { useEditorStore } from "../store"; + +/** Smooth scrolling unless the user asked for reduced motion. */ +export const getScrollBehavior = (): ScrollBehavior => + window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"; + +const SECTION_ANCHOR = "sidebar-"; +const ITEM_ANCHOR = atsFindingItemElementId(""); + +/** + * The block an element in the Write panel edits, read from the section and entry anchors around it, so focusing + * a field outlines its block on the page. The Basics card (and its photo) edit the header. + */ +export function selectionFromPanelElement(element: Element): EditorSelection | null { + const sectionId = element.closest(`[id^="${SECTION_ANCHOR}"]`)?.id.slice(SECTION_ANCHOR.length); + if (!sectionId) return null; + if (sectionId === "basics" || sectionId === "picture") return { kind: "header" }; + + const itemId = element.closest(`[id^="${ITEM_ANCHOR}"]`)?.id.slice(ITEM_ANCHOR.length); + return itemId ? { kind: "item", sectionId, itemId } : { kind: "section", sectionId }; +} + +/** + * Page → panel: opens what was picked (the Basics card for the header; otherwise its section, with Basics + * collapsed) and scrolls it 60px from the top of the panel, instantly with reduced motion. + */ +export function revealSelectionInPanel(selection: EditorSelection) { + const editor = useEditorStore.getState(); + editor.select(selection); + if (selection.kind === "header") editor.setBasicsOpen(true); + else { + editor.setBasicsOpen(false); + editor.setSectionOpen(selection.sectionId, true); + } + + requestAnimationFrame(() => { + const section = document.getElementById( + selection.kind === "header" ? "sidebar-basics" : `sidebar-${selection.sectionId}`, + ); + const entry = selection.kind === "item" ? document.getElementById(atsFindingItemElementId(selection.itemId)) : null; + (entry ?? section)?.scrollIntoView({ block: "start", behavior: getScrollBehavior() }); + }); +} diff --git a/apps/web/src/features/resume/editor/write/rich-text-editor.tsx b/apps/web/src/features/resume/editor/write/rich-text-editor.tsx new file mode 100644 index 000000000..30761c647 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/rich-text-editor.tsx @@ -0,0 +1,190 @@ +import type { IconName } from "@reactive-resume/ui/components/icon"; +import type { Editor } from "@tiptap/react"; +import type { ReactNode } from "react"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; +import { useEffect, useMemo, useState } from "react"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { cn } from "@reactive-resume/utils/style"; +import { hasUnsupportedTableMarkup, richInputExtensions } from "@/components/input/rich-input"; +import { usePrompt } from "@/hooks/use-prompt"; + +type ToolbarAction = { + icon: IconName; + label: string; + isActive?: (editor: Editor) => boolean; + run: (editor: Editor) => void; +}; + +/** + * Bold, Italic, Link, then lists and Clear formatting. Formatting the toolbar doesn't offer (headings, + * colours, alignment…) still loads and prints; Clear formatting removes it. + */ +function useToolbarActions(): ToolbarAction[] { + const prompt = usePrompt(); + + return [ + { + icon: "format_bold", + label: t`Bold`, + isActive: (editor) => editor.isActive("bold"), + run: (editor) => editor.chain().focus().toggleBold().run(), + }, + { + icon: "format_italic", + label: t`Italic`, + isActive: (editor) => editor.isActive("italic"), + run: (editor) => editor.chain().focus().toggleItalic().run(), + }, + { + icon: "link", + label: t`Link`, + isActive: (editor) => editor.isActive("link"), + run: async (editor) => { + const current = (editor.getAttributes("link").href as string | undefined) ?? ""; + const href = await prompt(t`Link address`, { + defaultValue: current || "https://", + description: t`Leave it empty to remove the link.`, + }); + if (href === null) return editor.commands.focus(); + const chain = editor.chain().focus().extendMarkRange("link"); + if (!href.trim() || href.trim() === "https://") chain.unsetLink().run(); + else chain.setLink({ href: href.trim() }).run(); + }, + }, + { + icon: "format_list_bulleted", + label: t`Bulleted list`, + isActive: (editor) => editor.isActive("bulletList"), + run: (editor) => editor.chain().focus().toggleBulletList().run(), + }, + { + icon: "format_list_numbered", + label: t`Numbered list`, + isActive: (editor) => editor.isActive("orderedList"), + run: (editor) => editor.chain().focus().toggleOrderedList().run(), + }, + { + icon: "format_clear", + label: t`Clear formatting`, + run: (editor) => editor.chain().focus().clearNodes().unsetAllMarks().unsetTextAlign().run(), + }, + ]; +} + +type RichTextEditorProps = { + /** Accessible name of the text box. */ + label: string; + value: string; + onChange: (html: string) => void; + /** Guidance under the text while editing, e.g. "2–3 sentences reads best". */ + hint?: ReactNode; + disabled?: boolean; + className?: string; +}; + +/** + * Rich text for descriptions: the toolbar and footer show while the text has focus, the toolbar never + * takes focus from it, and Markdown shortcuts work ("- " starts a list, "**bold**"). + */ +export function RichTextEditor({ label, value, onChange, hint, disabled = false, className }: RichTextEditorProps) { + const [focused, setFocused] = useState(false); + const actions = useToolbarActions(); + const readOnlyTable = useMemo(() => hasUnsupportedTableMarkup(value), [value]); + + const editor = useEditor({ + extensions: richInputExtensions, + content: value, + editable: !disabled && !readOnlyTable, + immediatelyRender: false, + shouldRerenderOnTransaction: false, + editorProps: { + attributes: { + "aria-label": label, + "aria-multiline": "true", + role: "textbox", + spellcheck: "true", + class: cn( + "wysiwyg max-h-[360px] min-h-[88px] overflow-y-auto px-3 py-2 text-sm outline-none", + "[&_[data-resume-whitespace=preserve]]:whitespace-pre-wrap", + ), + }, + }, + onUpdate: ({ editor }) => onChange(editor.getHTML()), + onFocus: () => setFocused(true), + onBlur: () => setFocused(false), + }); + + const state = useEditorState({ + editor, + selector: ({ editor }) => + editor + ? { characters: editor.getText().length, active: actions.map((action) => action.isActive?.(editor) ?? false) } + : { characters: 0, active: [] as boolean[] }, + }); + + // Undo, the page and the assistant change the text from outside; keep the editor in step. + useEffect(() => { + if (!editor || editor.getHTML() === value) return; + editor.commands.setContent(value, { emitUpdate: false }); + }, [editor, value]); + + useEffect(() => { + editor?.setEditable(!disabled && !readOnlyTable, false); + }, [editor, disabled, readOnlyTable]); + + return ( +
+ {focused && !readOnlyTable && ( +
+ {actions.map((action, index) => ( + + ))} +
+ )} + + {readOnlyTable && ( +

+ + Original table formatting is preserved. This content is read-only because it cannot be edited safely. + +

+ )} + + + + {focused && ( +
+ {hint ?? Markdown shortcuts on} + + + +
+ )} +
+ ); +} diff --git a/apps/web/src/features/resume/editor/write/section-row.tsx b/apps/web/src/features/resume/editor/write/section-row.tsx new file mode 100644 index 000000000..595219055 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/section-row.tsx @@ -0,0 +1,385 @@ +import type { SectionType } from "@reactive-resume/schema/resume/data"; +import type { KeyboardEvent, ReactNode } from "react"; +import type { WriteSection } from "./model"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { plural, t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { useState } from "react"; +import { sortSectionItemsByPeriod } from "@reactive-resume/resume/section-sort"; +import { Badge } from "@reactive-resume/ui/components/badge"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@reactive-resume/ui/components/dropdown-menu"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { IconButton } from "@reactive-resume/ui/components/icon-button"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { cn } from "@reactive-resume/utils/style"; +import { IconPicker } from "@/components/input/icon-picker"; +import { useCurrentBuilderResumeSelector, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft"; +import { usePrompt } from "@/hooks/use-prompt"; +import { getSectionTitle } from "@/libs/resume/section"; +import { useEditorStore } from "../store"; +import { addEntryTo } from "./actions"; +import { countEntriesToCheck, getEntries, getSectionObject } from "./model"; + +/** The printed title: the one the user gave, else the section type's name. */ +export function useSectionTitle(section: WriteSection) { + const title = useCurrentBuilderResumeSelector((resume) => getSectionObject(resume.data, section)?.title ?? ""); + return ( + title.trim() || getSectionTitle(section.kind === "custom" ? section.type : (section.id as SectionType | "summary")) + ); +} + +type SectionRowProps = { + section: WriteSection; + locked: boolean; + /** ⌥↑ / ⌥↓ on the focused title. */ + onMove: (direction: "up" | "down") => void; + children: ReactNode; +}; + +/** + * An outline row: drag handle, title (opens the section), entry count, the eye (hidden sections keep their + * content but aren't printed), the chevron and the ⋯ menu. Open, it shows the section's entries. + */ +export function SectionRow({ section, locked, onMove, children }: SectionRowProps) { + const title = useSectionTitle(section); + const hidden = useCurrentBuilderResumeSelector((resume) => getSectionObject(resume.data, section)?.hidden ?? false); + const count = useCurrentBuilderResumeSelector((resume) => getEntries(resume.data, section).length); + // Dates read from text that wasn't exact ask for a look (they print as typed until then). + const toCheck = useCurrentBuilderResumeSelector((resume) => countEntriesToCheck(getEntries(resume.data, section))); + const open = useEditorStore((state) => state.openSections.includes(section.id)); + const setOpen = useEditorStore((state) => state.setSectionOpen); + const updateResumeData = useUpdateResumeData(); + const sortable = useSortable({ id: section.id, disabled: locked }); + + const toggleHidden = () => + updateResumeData( + (draft) => { + const target = getSectionObject(draft, section); + if (target) target.hidden = !target.hidden; + }, + { newStep: true }, + ); + + const onTitleKeyDown = (event: KeyboardEvent) => { + if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; + event.preventDefault(); + onMove(event.key === "ArrowUp" ? "up" : "down"); + }; + + return ( +
+
+ + + + + {toCheck > 0 && ( + + + + )} + {section.kind !== "summary" && ( + + + {plural(count, { one: "# entry", other: "# entries" })} + + )} + + + setOpen(section.id, !open)} + /> + {!locked && } +
+ + {open &&
{children}
} +
+ ); +} + +type SectionMenuProps = { section: WriteSection; title: string; onMove: (direction: "up" | "down") => void }; + +/** Everything else a section can do: add, sort, heading, rename, icon, layout and reset or delete. */ +function SectionMenu({ section, title, onMove }: SectionMenuProps) { + const prompt = usePrompt(); + const updateResumeData = useUpdateResumeData(); + const settings = useCurrentBuilderResumeSelector((resume) => getSectionObject(resume.data, section) ?? null); + const locale = useCurrentBuilderResumeSelector((resume) => resume.data.metadata.page.locale); + const [iconPickerOpen, setIconPickerOpen] = useState(false); + if (!settings) return null; + + const edit = (mutate: (target: NonNullable>) => void) => + updateResumeData( + (draft) => { + const target = getSectionObject(draft, section); + if (target) mutate(target); + }, + { newStep: true }, + ); + + const undoToast = (description: string) => + toast.add({ description, actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() } }); + + const isSkills = section.type === "skills"; + const canSort = section.type === "experience" || section.type === "education"; + const columns = + isSkills && (settings as { layout?: string }).layout === "inline" ? "inline" : String(settings.columns); + + const sortByDate = () => { + let unresolved: string[] = []; + updateResumeData( + (draft) => { + const entries = getEntries(draft, section) as never as { + id: string; + period: string; + company?: string; + school?: string; + }[]; + const result = sortSectionItemsByPeriod(entries, locale); + unresolved = result.unresolvedIds.map((id) => { + const entry = entries.find((item) => item.id === id); + return (entry?.company || entry?.school || id).trim(); + }); + entries.splice(0, entries.length, ...result.items); + }, + { newStep: true }, + ); + if (unresolved.length > 0) { + toast.add({ + type: "warning", + description: t`Could not sort these items; they stayed at the end: ${unresolved.join(", ")}.`, + }); + } + }; + + const rename = async () => { + const next = await prompt(t`What do you want to rename this section to?`, { + description: t`Leave empty to reset the title to the original.`, + defaultValue: settings.title, + }); + if (next === null || next === settings.title) return; + edit((target) => { + target.title = next; + }); + }; + + const reset = () => { + edit((target) => { + if ("items" in target) target.items = []; + else target.content = ""; + }); + undoToast(t`${title} cleared`); + }; + + const remove = () => { + updateResumeData( + (draft) => { + draft.customSections = draft.customSections.filter((custom) => custom.id !== section.id); + for (const page of draft.metadata.layout.pages) { + page.main = page.main.filter((id) => id !== section.id); + page.sidebar = page.sidebar.filter((id) => id !== section.id); + } + }, + { newStep: true }, + ); + undoToast(t`${title} deleted`); + }; + + return ( + + + + } + /> + + {section.kind !== "summary" && ( + addEntryTo(section)}> + + Add entry + + )} + {canSort && ( + + + Sort by date + + )} + onMove("up")}> + + Move up + + onMove("down")}> + + Move down + + + + Rename… + + setIconPickerOpen(true)}> + + Icon… + + + edit((target) => { + target.showHeading = checked; + }) + } + > + Show heading + + + + + + Columns + + + + edit((target) => { + const skills = target as { layout?: string }; + if (isSkills) skills.layout = value === "inline" ? "inline" : "default"; + target.columns = value === "inline" ? 1 : Number(value); + }) + } + > + {[1, 2, 3, 4, 5, 6].map((column) => ( + + + + ))} + {isSkills && ( + + 1 column, inline + + )} + + + + {isSkills && ( + + + + Keyword layout + + + + edit((target) => { + (target as { keywordLayout?: string }).keywordLayout = value; + }) + } + > + + Inline + + + Bulleted list + + + + + )} + + edit((target) => { + target.keepTogether = checked; + }) + } + > + Keep on one page + + + edit((target) => { + target.startOnNewPage = checked; + }) + } + > + Start on a new page + + + + + {section.kind === "custom" ? Delete section : Clear section} + + + + + {/* The icon grid opens from the ⋯ button: an invisible trigger laid over it anchors the popover. */} + { + edit((target) => { + target.icon = icon === "" ? "none" : icon; + }); + setIconPickerOpen(false); + }} + popoverProps={{ open: iconPickerOpen, onOpenChange: setIconPickerOpen }} + aria-hidden + tabIndex={-1} + className="pointer-events-none absolute inset-0 size-full opacity-0" + /> + + ); +} diff --git a/apps/web/src/features/resume/editor/write/summary-editor.tsx b/apps/web/src/features/resume/editor/write/summary-editor.tsx new file mode 100644 index 000000000..614b01025 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/summary-editor.tsx @@ -0,0 +1,27 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; +import { RichTextEditor } from "./rich-text-editor"; + +/** The summary: one rich text, with the guidance the spec gives. Improve joins it in M10. */ +export function SummaryEditor({ locked }: { locked: boolean }) { + const content = useCurrentBuilderResumeSelector((resume) => resume.data.summary.content); + const updateResumeData = useUpdateResumeData(); + + return ( + 2–3 sentences reads best} + onChange={(html) => + updateResumeData( + (draft) => { + draft.summary.content = html; + }, + { coalesceKey: "summary.content" }, + ) + } + /> + ); +} diff --git a/apps/web/src/features/resume/editor/write/write-panel.test.tsx b/apps/web/src/features/resume/editor/write/write-panel.test.tsx new file mode 100644 index 000000000..3d1d00c60 --- /dev/null +++ b/apps/web/src/features/resume/editor/write/write-panel.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { Resume } from "@/features/resume/builder/draft"; +import { act, cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { parseResumeData } from "@reactive-resume/schema/resume/data"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { useResumeStore } from "@/features/resume/builder/draft"; +import { PromptDialogProvider } from "@/hooks/use-prompt"; +import { useEditorStore } from "../store"; +import { WritePanel } from "./write-panel"; + +const routerParams = vi.hoisted(() => ({ resumeId: "write-panel" })); +const toastState = vi.hoisted(() => ({ add: vi.fn() })); + +vi.mock("@tanstack/react-router", () => ({ useParams: () => routerParams })); +vi.mock("@/libs/orpc/client", () => ({ + orpc: { + resume: { + setLocked: { mutationOptions: () => ({}) }, + update: { call: vi.fn(() => new Promise(() => undefined)) }, + }, + }, + streamClient: { resume: { updates: { subscribe: vi.fn() } } }, +})); +vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: toastState.add, close: vi.fn() } })); + +beforeAll(() => { + i18n.loadAndActivate({ locale: "en-US", messages: {} }); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); +}); + +afterEach(() => { + cleanup(); + useEditorStore.getState().reset(); + toastState.add.mockClear(); +}); + +function renderPanel(edit?: (data: ResumeData) => void) { + const data = parseResumeData(structuredClone(sampleResumeData)); + edit?.(data); + const resume: Resume = { + id: routerParams.resumeId, + name: "Write panel", + slug: "write-panel", + tags: [], + data, + isLocked: false, + updatedAt: new Date("2026-09-28T00:00:00.000Z"), + }; + useResumeStore.getState().initialize(resume); + + render( + + + + + + + , + ); +} + +const data = () => useResumeStore.getState().resume?.data as ResumeData; +const sectionRow = (id: string) => document.getElementById(`sidebar-${id}`) as HTMLElement; + +describe("WritePanel", () => { + it("lists sections in print order and opens a section's entries", () => { + renderPanel(); + const experience = sectionRow("experience"); + + fireEvent.click(within(experience).getAllByRole("button", { name: "Experience" })[0] as HTMLElement); + + expect(within(experience).getByText("Cascade Studios", { exact: false })).toBeInTheDocument(); + expect(within(experience).getByRole("button", { name: "Add experience" })).toBeInTheDocument(); + }); + + it("adds a draft entry, open and focused on its first field, that prints once it has a company", async () => { + renderPanel(); + const experience = sectionRow("experience"); + fireEvent.click(within(experience).getAllByRole("button", { name: "Experience" })[0] as HTMLElement); + + fireEvent.click(within(experience).getByRole("button", { name: "Add experience" })); + + await waitFor(() => expect(within(experience).getByText("Draft · not printed")).toBeInTheDocument()); + expect(document.activeElement).toHaveAttribute("data-entry-field", "position"); + + fireEvent.change(within(experience).getByRole("textbox", { name: "Company" }), { target: { value: "Lumen" } }); + + expect(within(experience).queryByText("Draft · not printed")).not.toBeInTheDocument(); + expect(data().sections.experience.items.at(-1)).toMatchObject({ company: "Lumen" }); + }); + + it("deletes an entry at once and offers Undo", () => { + renderPanel(); + const [first] = data().sections.education.items; + act(() => useEditorStore.getState().select({ kind: "item", sectionId: "education", itemId: first?.id ?? "" })); + act(() => useEditorStore.getState().setSectionOpen("education", true)); + + fireEvent.click(within(sectionRow("education")).getByRole("button", { name: "Delete entry" })); + + expect(data().sections.education.items).toHaveLength(0); + const toast = toastState.add.mock.calls.at(-1)?.[0]; + expect(toast).toMatchObject({ description: "Entry deleted", actionProps: { children: "Undo" } }); + + act(() => toast.actionProps.onClick()); + expect(data().sections.education.items).toHaveLength(1); + }); + + it("moves a section with ⌥↑ on its title, changing the print order", () => { + renderPanel(); + const before = data().metadata.layout.pages[0]?.main ?? []; + const index = before.indexOf("education"); + const title = within(sectionRow("education")).getAllByRole("button", { name: "Education" })[0] as HTMLElement; + + fireEvent.keyDown(title, { key: "ArrowUp", altKey: true }); + + const after = data().metadata.layout.pages[0]?.main ?? []; + expect(after.indexOf("education")).toBe(index - 1); + }); + + it("keeps hidden sections listed, struck through, with the eye to show them", () => { + renderPanel((draft) => { + draft.sections.skills.hidden = true; + }); + + fireEvent.click(within(sectionRow("skills")).getByRole("button", { name: "Show Skills on the page" })); + + expect(data().sections.skills.hidden).toBe(false); + }); +}); diff --git a/apps/web/src/features/resume/editor/write/write-panel.tsx b/apps/web/src/features/resume/editor/write/write-panel.tsx new file mode 100644 index 000000000..451a11abe --- /dev/null +++ b/apps/web/src/features/resume/editor/write/write-panel.tsx @@ -0,0 +1,101 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useMutation } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert"; +import { Button } from "@reactive-resume/ui/components/button"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { useDialogStore } from "@/dialogs/store"; +import { + useCurrentBuilderResumeSelector, + useCurrentResume, + useIsResumeLocked, + usePatchResume, +} from "@/features/resume/builder/draft"; +import { getResumeErrorMessage } from "@/libs/error-message"; +import { orpc } from "@/libs/orpc/client"; +import { useEditorStore } from "../store"; +import { AddSectionMenu, StartSuggestions } from "./add-section"; +import { BasicsCard } from "./basics-card"; +import { getOutlineRows } from "./model"; +import { Outline } from "./outline"; + +/** + * Write: the Basics card, then the outline of sections in print order with their entries, then Add + * section. Every field saves as you type; a locked resume shows the same panel read-only. + */ +export function WritePanel() { + const locked = useIsResumeLocked(); + const locale = useCurrentBuilderResumeSelector((resume) => resume.data.metadata.page.locale); + // The selector hook reads `undefined` as "no resume yet", so optional values need a fallback. + const dateFormat = useCurrentBuilderResumeSelector((resume) => resume.data.metadata.page.dateFormat ?? null); + const page = useMemo(() => ({ locale, dateFormat: dateFormat ?? undefined }), [locale, dateFormat]); + const added = useEditorStore((state) => state.addedSections); + const isEmpty = useCurrentBuilderResumeSelector((resume) => getOutlineRows(resume.data, new Set(added)).length === 0); + const openDialog = useDialogStore((state) => state.openDialog); + + return ( +
+ {locked && } + + + +
+

+ Sections · print order + {!locked && ( + + drag · ⌥↑↓ + + )} +

+ + {isEmpty && !locked ? ( + openDialog("resume.import", undefined)} /> + ) : ( + + )} + + {!locked && } +
+ +

+ Hidden sections keep their content but aren't printed or shared. Fields save as you type. +

+
+ ); +} + +/** Locked resumes read as they print; Unlock is one click and reversible. */ +function LockedNote() { + const resume = useCurrentResume(); + const patchResume = usePatchResume(); + const { mutate: setLocked, isPending } = useMutation(orpc.resume.setLocked.mutationOptions()); + + const unlock = () => + setLocked( + { id: resume.id, isLocked: false }, + { + onSuccess: () => + patchResume((draft) => { + draft.isLocked = false; + }), + onError: (error) => toast.add({ type: "error", description: getResumeErrorMessage(error) }), + }, + ); + + return ( + + + + + Locked. Unlock to edit. + + + + + ); +} diff --git a/apps/web/src/features/resume/preview/preview.browser.test.tsx b/apps/web/src/features/resume/preview/preview.browser.test.tsx index 4a18aa3ee..a69037cd2 100644 --- a/apps/web/src/features/resume/preview/preview.browser.test.tsx +++ b/apps/web/src/features/resume/preview/preview.browser.test.tsx @@ -45,6 +45,7 @@ vi.mock("@reactive-resume/ui/components/toast", () => ({ })); vi.mock("../builder/draft", () => ({ + isEditableElementFocused: () => false, useResumeData: () => previewMock.builderResumeData, usePreviewPausedStore: (selector: (state: { paused: boolean }) => unknown) => selector({ paused: false }), })); diff --git a/apps/web/src/features/resume/preview/preview.browser.tsx b/apps/web/src/features/resume/preview/preview.browser.tsx index 457af3192..3c91eb783 100644 --- a/apps/web/src/features/resume/preview/preview.browser.tsx +++ b/apps/web/src/features/resume/preview/preview.browser.tsx @@ -11,7 +11,7 @@ import { isRTL } from "@reactive-resume/utils/locale"; import { cn } from "@reactive-resume/utils/style"; import { createResumePdfBlob } from "@/features/resume/export/pdf-document"; import { EASE_OUT_STRONG } from "@/libs/motion"; -import { usePreviewPausedStore, useResumeData } from "../builder/draft"; +import { isEditableElementFocused, usePreviewPausedStore, useResumeData } from "../builder/draft"; import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas"; import { ResumePreviewLoader } from "./preview.shared"; import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils"; @@ -29,6 +29,9 @@ type PreviewPdf = { }; const UPDATE_DEBOUNCE_MS = 100; +// While a field has focus the page waits for a pause in typing, so rendering (about 230 ms for two pages, +// on the main thread) doesn't compete with keystrokes. +const TYPING_DEBOUNCE_MS = 250; // Incoming layer fades in over the old one; the old layer holds at full opacity until the incoming one is opaque, // then drops out. Fading both at once dips the page towards the background mid-swap. const INCOMING_TRANSITION = { duration: 0.15, ease: EASE_OUT_STRONG }; @@ -132,7 +135,7 @@ export function ResumePreviewClient({ let cancelled = false; const requestId = ++requestIdRef.current; - const delay = hasPreviewRef.current ? UPDATE_DEBOUNCE_MS : 0; + const delay = !hasPreviewRef.current ? 0 : isEditableElementFocused() ? TYPING_DEBOUNCE_MS : UPDATE_DEBOUNCE_MS; const generatePdfPreview = async () => { try { diff --git a/apps/web/src/libs/resume/make-section-item.test.ts b/apps/web/src/libs/resume/make-section-item.test.ts deleted file mode 100644 index 9bb1618cf..000000000 --- a/apps/web/src/libs/resume/make-section-item.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { makeSectionItem } from "./make-section-item"; - -describe("makeSectionItem", () => { - it("clones the provided item but generates a fresh id", () => { - const original = { id: "original-id", name: "Skill" }; - const result = makeSectionItem({ id: "default", name: "" }, original); - - expect(result.name).toBe("Skill"); - expect(result.id).not.toBe("original-id"); - expect(result.id.length).toBeGreaterThan(0); - }); - - it("uses defaultItem when no item is provided", () => { - const defaultItem = { id: "default", name: "", level: 0 }; - const result = makeSectionItem(defaultItem); - - expect(result.name).toBe(""); - expect(result.level).toBe(0); - expect(result.id).not.toBe("default"); - }); - - it("does not mutate the input item when duplicating", () => { - const original = { id: "original-id", value: "test" }; - const before = JSON.stringify(original); - makeSectionItem({ id: "default", value: "" }, original); - expect(JSON.stringify(original)).toBe(before); - }); - - it("does not mutate the defaultItem", () => { - const defaultItem = { id: "default", value: "x" }; - const before = JSON.stringify(defaultItem); - makeSectionItem(defaultItem); - expect(JSON.stringify(defaultItem)).toBe(before); - }); - - it("generates a unique id per call", () => { - const a = makeSectionItem({ id: "default", name: "" }); - const b = makeSectionItem({ id: "default", name: "" }); - expect(a.id).not.toBe(b.id); - }); - - it("preserves all other fields when duplicating", () => { - const item = { id: "x", a: 1, b: { c: 2 }, d: [3] }; - const result = makeSectionItem({ id: "default", a: 0, b: { c: 0 }, d: [] }, item); - expect(result.a).toBe(1); - expect(result.b).toEqual({ c: 2 }); - expect(result.d).toEqual([3]); - }); -}); diff --git a/apps/web/src/libs/resume/make-section-item.ts b/apps/web/src/libs/resume/make-section-item.ts deleted file mode 100644 index 8c7ea3ca1..000000000 --- a/apps/web/src/libs/resume/make-section-item.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { generateId } from "@reactive-resume/utils/string"; - -/** - * Resolves initial values for section-item create dialogs. - * - * Expected usage: - * - Duplicate flow: pass a fully schema-valid `item`; this helper clones it and always generates a fresh `id`. - * - Create flow: pass no `item`; this helper uses `createItem` and still guarantees a fresh `id`. - * - * This helper intentionally does not deep-merge partial items. Callers should provide either a complete item - * (duplicate) or no item (create) to keep the seam explicit and predictable. - */ -export function makeSectionItem(defaultItem: T, item?: T): T { - if (item) return { ...item, id: generateId() }; - return { ...defaultItem, id: generateId() }; -} diff --git a/apps/web/src/libs/resume/section-actions.test.ts b/apps/web/src/libs/resume/section-actions.test.ts deleted file mode 100644 index 777d70a87..000000000 --- a/apps/web/src/libs/resume/section-actions.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { produce } from "immer"; -import { defaultResumeData } from "@reactive-resume/schema/resume/default"; -import { createSectionItem, updateSectionItem } from "./section-actions"; - -describe("createSectionItem", () => { - it("appends to a built-in section's items array", () => { - const result = produce(defaultResumeData, (draft) => { - createSectionItem(draft, "skills", { id: "1", name: "Go" }); - }); - expect(result.sections.skills.items).toHaveLength(1); - expect((result.sections.skills.items[0] as { name?: string })?.name).toBe("Go"); - }); - - it("appends to a custom section by id", () => { - const initial = produce(defaultResumeData, (draft) => { - draft.customSections.push({ - id: "custom-1", - type: "cover-letter", - title: "Cover Letter", - icon: "", - columns: 1, - hidden: false, - keepTogether: false, - startOnNewPage: false, - items: [], - }); - }); - - const result = produce(initial, (draft) => { - createSectionItem(draft, "skills", { id: "1", text: "Custom item" }, "custom-1"); - }); - - const customSection = result.customSections.find((s) => s.id === "custom-1"); - expect(customSection?.items).toHaveLength(1); - // Built-in section is untouched - expect(result.sections.skills.items).toHaveLength(0); - }); - - it("does nothing when customSectionId does not match", () => { - const result = produce(defaultResumeData, (draft) => { - createSectionItem(draft, "skills", { id: "1", name: "x" }, "non-existent"); - }); - // Skills not touched, no custom section to modify - expect(result.sections.skills.items).toHaveLength(0); - }); -}); - -describe("updateSectionItem", () => { - it("replaces the matching item in a built-in section", () => { - const initial = produce(defaultResumeData, (draft) => { - createSectionItem(draft, "skills", { id: "abc", name: "Go", level: 4 }); - }); - - const result = produce(initial, (draft) => { - updateSectionItem(draft, "skills", { id: "abc", name: "Go", level: 5 }); - }); - - const item = result.sections.skills.items[0] as { id?: string; name?: string; level?: number }; - expect(item?.level).toBe(5); - }); - - it("does nothing when item id does not exist", () => { - const initial = produce(defaultResumeData, (draft) => { - createSectionItem(draft, "skills", { id: "abc", name: "Go" }); - }); - - const before = JSON.stringify(initial.sections.skills); - const result = produce(initial, (draft) => { - updateSectionItem(draft, "skills", { id: "non-existent", name: "X" }); - }); - expect(JSON.stringify(result.sections.skills)).toBe(before); - }); - - it("replaces the matching item in a custom section", () => { - const initial = produce(defaultResumeData, (draft) => { - draft.customSections.push({ - id: "custom-1", - type: "cover-letter", - title: "", - icon: "", - columns: 1, - hidden: false, - keepTogether: false, - startOnNewPage: false, - items: [{ id: "x", value: "old" } as never], - }); - }); - - const result = produce(initial, (draft) => { - updateSectionItem(draft, "skills", { id: "x", value: "new" }, "custom-1"); - }); - - const customSection = result.customSections.find((s) => s.id === "custom-1"); - if (!customSection) throw new Error("Custom section not found"); - expect((customSection.items[0] as { value?: string }).value).toBe("new"); - }); - - it("does nothing when custom section is not found", () => { - const before = JSON.stringify(defaultResumeData); - const result = produce(defaultResumeData, (draft) => { - updateSectionItem(draft, "skills", { id: "x", value: "new" }, "non-existent"); - }); - expect(JSON.stringify(result)).toBe(before); - }); -}); diff --git a/apps/web/src/libs/resume/section-actions.ts b/apps/web/src/libs/resume/section-actions.ts deleted file mode 100644 index 75106868e..000000000 --- a/apps/web/src/libs/resume/section-actions.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data"; -import type { WritableDraft } from "immer"; - -/** - * Pushes a new item into a section's items array. - * Handles both built-in sections and custom sections. - */ -export function createSectionItem( - draft: WritableDraft, - sectionKey: SectionType, - formData: Record, - customSectionId?: string, -) { - if (customSectionId) { - const section = draft.customSections.find((s) => s.id === customSectionId); - if (section) section.items.push(formData as never); - } else { - (draft.sections[sectionKey].items as unknown[]).push(formData); - } -} - -/** - * Finds and replaces an existing item in a section's items array by id. - * Handles both built-in sections and custom sections. - */ -export function updateSectionItem( - draft: WritableDraft, - sectionKey: SectionType, - formData: { id: string } & Record, - customSectionId?: string, -) { - if (customSectionId) { - const section = draft.customSections.find((s) => s.id === customSectionId); - if (!section) return; - const index = section.items.findIndex((item) => item.id === formData.id); - if (index !== -1) section.items[index] = formData as never; - } else { - const items = draft.sections[sectionKey].items as Array<{ id: string }>; - const index = items.findIndex((item) => item.id === formData.id); - if (index !== -1) (items[index] as unknown as Record) = formData; - } -} diff --git a/apps/web/src/libs/resume/section.tsx b/apps/web/src/libs/resume/section.tsx index e08bc97bf..c1753505a 100644 --- a/apps/web/src/libs/resume/section.tsx +++ b/apps/web/src/libs/resume/section.tsx @@ -35,8 +35,6 @@ import { import { match } from "ts-pattern"; import { cn } from "@reactive-resume/utils/style"; -export { defaultSectionIconNames } from "@reactive-resume/schema/resume/section-icons"; - export type LeftSidebarSection = "picture" | "basics" | "summary" | SectionType | "custom"; // CustomSectionType values that are not in SectionType (used in custom sections only) diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx index 7c0a16655..cac3cba75 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx @@ -10,9 +10,9 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; import { cn } from "@reactive-resume/utils/style"; import { usePreviewPausedStore } from "@/features/resume/builder/draft"; import { useEditorStore } from "@/features/resume/editor/store"; +import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal"; import { EditorBar } from "./editor-bar"; import { ModePanel } from "./mode-panels"; -import { revealSelectionInPanel } from "./reveal-selection"; import { ShareSheet } from "./share-sheet"; import { useEditorHotkeys } from "./use-editor-hotkeys"; import { useEditorMode } from "./use-editor-mode"; diff --git a/apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx b/apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx index efef6f07e..9a4309ee7 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx @@ -3,7 +3,8 @@ import { Fragment } from "react"; import { Separator } from "@reactive-resume/ui/components/separator"; import { OfflineBanner } from "@/features/resume/editor/save-status"; import { useEditorStore } from "@/features/resume/editor/store"; -import { BuilderWritePanel } from "../-sidebar/left"; +import { selectionFromPanelElement } from "@/features/resume/editor/write/reveal"; +import { WritePanel } from "@/features/resume/editor/write/write-panel"; import { AtsCheckSectionBuilder } from "../-sidebar/right/sections/ats-check"; import { CustomStylesSectionBuilder } from "../-sidebar/right/sections/custom-styles"; import { DesignSectionBuilder } from "../-sidebar/right/sections/design"; @@ -12,7 +13,6 @@ import { PageSectionBuilder } from "../-sidebar/right/sections/page"; import { TemplateSectionBuilder } from "../-sidebar/right/sections/template"; import { TypographySectionBuilder } from "../-sidebar/right/sections/typography"; import { BareSectionChrome } from "../-sidebar/right/shared/section-base"; -import { selectionFromPanelElement } from "./reveal-selection"; // Until the Design mode is rebuilt (M4), it hosts the existing design sections in this order. const DESIGN_SECTIONS = [ @@ -48,7 +48,7 @@ function CheckPanel() { ); } -function WritePanel() { +function WriteMode() { const select = useEditorStore((state) => state.select); return ( @@ -56,11 +56,13 @@ function WritePanel() { // biome-ignore lint/a11y/noStaticElementInteractions: not an interactive element, see above.
{ + // Fields only: buttons (an entry's title, the chevrons) change the selection themselves. + if (!event.target.matches("input, textarea, select, [contenteditable='true']")) return; const selection = selectionFromPanelElement(event.target); if (selection) select(selection); }} > - +
); } @@ -69,7 +71,7 @@ export function ModePanel({ mode }: { mode: EditorMode }) { return ( <> - {mode === "design" ? : mode === "check" ? : } + {mode === "design" ? : mode === "check" ? : } ); } diff --git a/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx b/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx index fe3f7da6f..c753cdfe9 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx @@ -9,8 +9,8 @@ import { cn } from "@reactive-resume/utils/style"; import { useCurrentBuilderResumeSelector } from "@/features/resume/builder/draft"; import { PageOverlay } from "@/features/resume/editor/page-overlay"; import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/features/resume/editor/store"; +import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal"; import { ResumePreview } from "@/features/resume/preview/preview"; -import { revealSelectionInPanel } from "./reveal-selection"; import { useEditorMode } from "./use-editor-mode"; // Page widths in PDF points; 1pt renders as 1 CSS px at 100%. diff --git a/apps/web/src/routes/builder/$resumeId/-components/reveal-selection.ts b/apps/web/src/routes/builder/$resumeId/-components/reveal-selection.ts deleted file mode 100644 index 3aa4e7077..000000000 --- a/apps/web/src/routes/builder/$resumeId/-components/reveal-selection.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { EditorSelection } from "@/features/resume/editor/store"; -import type { LeftSidebarSection } from "@/libs/resume/section"; -import { - focusCustomSidebarSection, - focusLeftSidebarSection, - getScrollBehavior, -} from "@/features/resume/builder/section-recovery"; -import { atsFindingItemElementId } from "@/libs/resume/ats"; -import { leftSidebarSections } from "@/libs/resume/section"; -import { useSectionStore } from "../-store/section"; - -const isBuiltInSection = (sectionId: string): sectionId is LeftSidebarSection => - (leftSidebarSections as readonly string[]).includes(sectionId); - -const SECTION_ANCHOR = "sidebar-"; -const ITEM_ANCHOR = atsFindingItemElementId(""); - -/** - * The block an element in the Write panel edits, read from the section and entry anchors around it, so focusing - * a field outlines its block on the page. Picture and Basics edit the header. - */ -export function selectionFromPanelElement(element: Element): EditorSelection | null { - const sectionId = element.closest(`[id^="${SECTION_ANCHOR}"]`)?.id.slice(SECTION_ANCHOR.length); - // "sidebar-hidden-…" rows restore hidden sections; they have no block on the page. - if (!sectionId || sectionId.startsWith("hidden-")) return null; - if (sectionId === "basics" || sectionId === "picture") return { kind: "header" }; - - const itemId = element.closest(`[id^="${ITEM_ANCHOR}"]`)?.id.slice(ITEM_ANCHOR.length); - return itemId ? { kind: "item", sectionId, itemId } : { kind: "section", sectionId }; -} - -/** - * Brings the selected block's editor into view in the Write panel: expands its section and scrolls the - * entry to the middle. The Write panel is rebuilt in M3; until then this targets the existing section list. - */ -export function revealSelectionInPanel(selection: EditorSelection) { - const sectionKey: LeftSidebarSection = - selection.kind === "header" ? "basics" : isBuiltInSection(selection.sectionId) ? selection.sectionId : "custom"; - useSectionStore.getState().setCollapsed(sectionKey, false); - - requestAnimationFrame(() => { - if (selection.kind === "item") { - const row = document.getElementById(atsFindingItemElementId(selection.itemId)); - if (row) { - row.scrollIntoView({ block: "center", behavior: getScrollBehavior() }); - return; - } - } - - if (selection.kind === "header") focusLeftSidebarSection("basics"); - else if (isBuiltInSection(selection.sectionId)) focusLeftSidebarSection(selection.sectionId); - else focusCustomSidebarSection(selection.sectionId); - }); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx deleted file mode 100644 index 11285fe8d..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { LeftSidebarSection } from "@/libs/resume/section"; -import { Trans } from "@lingui/react/macro"; -import { LockSimpleIcon } from "@phosphor-icons/react"; -import { useMutation } from "@tanstack/react-query"; -import { match } from "ts-pattern"; -import { Button } from "@reactive-resume/ui/components/button"; -import { toast } from "@reactive-resume/ui/components/toast"; -import { useCurrentResume, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft"; -import { SectionEditorList } from "@/features/resume/builder/section-recovery"; -import { getResumeErrorMessage } from "@/libs/error-message"; -import { orpc } from "@/libs/orpc/client"; -import { BasicsSectionBuilder } from "./sections/basics"; -import { CustomSectionBuilder } from "./sections/custom"; -import { PictureSectionBuilder } from "./sections/picture"; -import { SummarySectionBuilder } from "./sections/summary"; -import { ItemsSection } from "./shared/items-section"; - -function getSectionComponent(type: LeftSidebarSection) { - return match(type) - .with("picture", () => ) - .with("basics", () => ) - .with("summary", () => ) - .with( - "profiles", - "experience", - "education", - "projects", - "skills", - "languages", - "interests", - "awards", - "certifications", - "publications", - "volunteer", - "references", - (type) => , - ) - .with("custom", () => ) - .exhaustive(); -} - -/** The Write mode panel: the section editors. It's rebuilt as the outline in M3. */ -export function BuilderWritePanel() { - const isLocked = useIsResumeLocked(); - - return ( -
- {isLocked && } - -
- -
-
- ); -} - -function LockBanner() { - const resume = useCurrentResume(); - const patchResume = usePatchResume(); - const { mutate: setLocked, isPending } = useMutation(orpc.resume.setLocked.mutationOptions()); - - const handleUnlock = () => { - setLocked( - { id: resume.id, isLocked: false }, - { - onSuccess: () => { - patchResume((draft) => { - draft.isLocked = false; - }); - }, - onError: (error) => { - toast.add({ type: "error", description: getResumeErrorMessage(error) }); - }, - }, - ); - }; - - return ( -
- -
-

- This resume is locked -

-

- Editing is disabled until you unlock it. -

-
- -
- ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx deleted file mode 100644 index f74950dbd..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type z from "zod"; -import { Trans } from "@lingui/react/macro"; -import { basicsSchema } from "@reactive-resume/schema/resume/data"; -import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { URLInput } from "@/components/input/url-input"; -import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useSyncFormValues } from "@/hooks/use-sync-form-values"; -import { useAppForm } from "@/libs/tanstack-form"; -import { SectionBase } from "../shared/section-base"; -import { CustomFieldsSection } from "./custom-fields"; - -export function BasicsSectionBuilder() { - return ( - - - - ); -} - -const formSchema = basicsSchema; - -type FormValues = z.infer; - -function BasicsSectionForm() { - const basics = useCurrentBuilderResumeSelector((resume) => resume.data.basics); - const updateResumeData = useUpdateResumeData(); - - const persist = (data: FormValues) => { - updateResumeData((draft) => { - draft.basics = data; - }); - }; - - const form = useAppForm({ - defaultValues: basics, - validators: { onChange: formSchema }, - // Persist on every field change via a form-level listener. Previously each field called - // `form.handleSubmit()` on change, which re-validated the whole form AND toggled submit state — - // firing the render cascade twice per keystroke. A listener persists once, without the submit churn. - listeners: { - onChange: ({ formApi }) => { - persist(formApi.state.values); - }, - }, - onSubmit: ({ value }) => { - persist(value); - }, - }); - useSyncFormValues(form, basics); - - return ( -
{ - event.preventDefault(); - event.stopPropagation(); - void form.handleSubmit(); - }} - > - - {(field) => ( - 0}> - - Name - - { - field.handleChange(e.target.value); - }} - /> - } - /> - - - )} - - - - {(field) => ( - 0}> - - Headline - - { - field.handleChange(e.target.value); - }} - /> - } - /> - - - )} - - - - {(field) => ( - 0}> - - Email - - { - field.handleChange(e.target.value); - }} - /> - } - /> - - - )} - - - - {(field) => ( - 0}> - - Phone - - { - field.handleChange(e.target.value); - }} - /> - } - /> - - - )} - - - - {(field) => ( - 0}> - - Location - - { - field.handleChange(e.target.value); - }} - /> - } - /> - - - )} - - - - {(field) => ( - 0}> - - Website - - { - field.handleChange(value); - }} - /> - } - /> - - - )} - - - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom-fields.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom-fields.tsx deleted file mode 100644 index 0365d9677..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom-fields.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import type { basicsSchema } from "@reactive-resume/schema/resume/data"; -import type z from "zod"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { DotsSixVerticalIcon, LinkIcon, ListPlusIcon, XIcon } from "@phosphor-icons/react"; -import { Reorder, useDragControls } from "motion/react"; -import { Button } from "@reactive-resume/ui/components/button"; -import { FormControl, FormItem } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { Label } from "@reactive-resume/ui/components/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover"; -import { generateId } from "@reactive-resume/utils/string"; -import { IconPicker } from "@/components/input/icon-picker"; -import { withForm } from "@/libs/tanstack-form"; - -type FormValues = z.infer; -type CustomField = FormValues["customFields"][number]; - -const defaultValues: FormValues = { - name: "", - headline: "", - email: "", - phone: "", - location: "", - website: { url: "", label: "" }, - customFields: [], -}; - -export const CustomFieldsSection = withForm({ - defaultValues, - render: ({ form }) => { - return ( - - {(customFieldsField) => ( - { - customFieldsField.setValue(fields); - }} - > - {customFieldsField.state.value.map((field: CustomField, index: number) => ( - - - {(iconField) => ( - - { - iconField.handleChange(icon); - }} - /> - } - /> - - )} - - - - {(textField) => ( - - { - textField.handleChange(e.target.value); - }} - /> - } - /> - - )} - - - - {(linkField) => ( - - - - - } - /> - - -
- - - { - linkField.handleChange(e.target.value); - }} - /> -
-
-
- )} -
- - -
- ))} - - -
- )} -
- ); - }, -}); - -type CustomFieldItemProps = { - field: CustomField; - children: React.ReactNode; -}; - -function CustomFieldItem({ field, children }: CustomFieldItemProps) { - const controls = useDragControls(); - - return ( - - - - {children} - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx deleted file mode 100644 index 4de3a4e65..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import type { - CustomSection, - CustomSectionItem as CustomSectionItemType, - CustomSectionType, -} from "@reactive-resume/schema/resume/data"; -import { t } from "@lingui/core/macro"; -import { Plural, Trans } from "@lingui/react/macro"; -import { - ColumnsIcon, - CopySimpleIcon, - DotsThreeVerticalIcon, - EyeClosedIcon, - EyeIcon, - PencilSimpleLineIcon, - TrashSimpleIcon, -} from "@phosphor-icons/react"; -import { Badge } from "@reactive-resume/ui/components/badge"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@reactive-resume/ui/components/dropdown-menu"; -import { stripHtml } from "@reactive-resume/utils/string"; -import { cn } from "@reactive-resume/utils/style"; -import { useDialogStore } from "@/dialogs/store"; -import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useConfirm } from "@/hooks/use-confirm"; -import { getSectionTitle } from "@/libs/resume/section"; -import { SECTIONS, SectionItemList } from "../shared/items-section"; -import { SectionBase } from "../shared/section-base"; -import { SectionAddItemButton } from "../shared/section-item"; -import { SkillKeywordLayoutMenu } from "../shared/skill-keyword-layout-menu"; - -function truncateHtml(html: string, max = 50): string { - const stripped = stripHtml(html); - return stripped.length > max ? `${stripped.slice(0, max)}...` : stripped; -} - -function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): string { - if (type === "summary") { - if ("content" in item) { - return ( - truncateHtml(item.content) || - t({ - comment: "Fallback title for a custom summary item in resume builder when content is empty", - message: "Summary", - }) - ); - } - return t({ - comment: "Fallback title for a custom summary item in resume builder when content is unavailable", - message: "Summary", - }); - } - if (type === "cover-letter") { - if ("recipient" in item) { - return ( - truncateHtml(item.recipient) || - t({ - comment: "Fallback title for a custom cover letter item in resume builder when recipient is empty", - message: "Cover Letter", - }) - ); - } - return t({ - comment: "Fallback title for a custom cover letter item in resume builder when recipient is unavailable", - message: "Cover Letter", - }); - } - return SECTIONS[type].title(item as never); -} - -function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType): string | undefined { - if (type === "cover-letter") { - if ("content" in item) { - const result = truncateHtml(item.content); - return result || undefined; - } - return undefined; - } - if (type === "summary") return undefined; - return SECTIONS[type].subtitle?.(item as never) || undefined; -} - -export function CustomSectionBuilder() { - const customSections = useCurrentBuilderResumeSelector((resume) => resume.data.customSections); - - return ( - - {customSections - .filter((section) => !section.hidden) - .map((section) => ( - - ))} - - {/* Add Custom Section Button */} - - Add a new custom section - - - ); -} - -type CustomSectionContainerProps = { - section: CustomSection; -}; - -function CustomSectionContainer({ section }: CustomSectionContainerProps) { - const { openDialog } = useDialogStore(); - const updateResumeData = useUpdateResumeData(); - - const onUpdateSection = () => { - openDialog("resume.sections.custom.update", section); - }; - - const handleReorder = (items: CustomSectionItemType[]) => { - updateResumeData((draft) => { - const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id); - if (sectionIndex === -1) return; - draft.customSections[sectionIndex].items = items; - }); - }; - - return ( -
- {/* Section Header */} -
- - - -
- - {/* Section Items */} - {section.items.length > 0 && ( -
- getItemTitle(section.type, item)} - getSubtitle={(item) => getItemSubtitle(section.type, item)} - /> -
- )} - - {/* Add Item Button */} -
- - Add a new item - -
-
- ); -} - -type CustomSectionDropdownMenuProps = { - section: CustomSection; -}; - -function CustomSectionDropdownMenu({ section }: CustomSectionDropdownMenuProps) { - const confirm = useConfirm(); - const { openDialog } = useDialogStore(); - const updateResumeData = useUpdateResumeData(); - const showHeading = section.showHeading !== false; - - const onToggleSectionVisibility = () => { - updateResumeData((draft) => { - const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id); - if (sectionIndex === -1) return; - draft.customSections[sectionIndex].hidden = !draft.customSections[sectionIndex].hidden; - }); - }; - - const onToggleHeading = () => { - updateResumeData((draft) => { - const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id); - if (sectionIndex === -1) return; - draft.customSections[sectionIndex].showHeading = !(draft.customSections[sectionIndex].showHeading !== false); - }); - }; - - const onUpdateSection = () => { - openDialog("resume.sections.custom.update", section); - }; - - const onDuplicateSection = () => { - openDialog("resume.sections.custom.create", section); - }; - - const onSetColumns = (value: string) => { - updateResumeData((draft) => { - const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id); - if (sectionIndex === -1) return; - draft.customSections[sectionIndex].columns = Number.parseInt(value, 10); - }); - }; - - const onDeleteSection = async () => { - const confirmed = await confirm(t`Are you sure you want to delete this custom section?`, { - confirmText: t({ - comment: "Destructive confirmation button label when deleting a custom section in resume builder", - message: "Delete", - }), - cancelText: t({ - comment: "Confirmation dialog button label to abort deleting a custom section in resume builder", - message: "Cancel", - }), - }); - - if (!confirmed) return; - - updateResumeData((draft) => { - draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id); - draft.metadata.layout.pages = draft.metadata.layout.pages.map((page) => ({ - ...page, - main: page.main.filter((id) => id !== section.id), - sidebar: page.sidebar.filter((id) => id !== section.id), - })); - }); - }; - - return ( - - - - - - - - - {section.hidden ? : } - {section.hidden ? Show : Hide} - - - - {showHeading ? : } - {showHeading ? Hide heading : Show heading} - - - - - Update - - - - - Duplicate - - - {section.type === "skills" && } - - - - - Columns - - - - - {[1, 2, 3, 4, 5, 6].map((column) => ( - - - - ))} - - - - - - - - - - - Delete - - - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education-experience.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education-experience.test.tsx deleted file mode 100644 index f4fad520b..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education-experience.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -// @vitest-environment happy-dom - -import type { Resume } from "@/features/resume/builder/draft"; -import { render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -const educationItems = vi.hoisted(() => [ - { - id: "e1", - school: "MIT", - degree: "BS", - area: "CS", - grade: "", - location: "", - period: "2010-2014", - description: "", - hidden: false, - website: { url: "", label: "", inlineLink: false }, - }, -]); -const experienceItems = vi.hoisted(() => [ - // Experience with position set - { - id: "x1", - company: "Acme", - position: "Senior Engineer", - location: "", - period: "2020-2024", - description: "", - hidden: false, - website: { url: "", label: "", inlineLink: false }, - roles: [], - }, - // Experience with empty position and multiple roles → falls back to "N roles" - { - id: "x2", - company: "BetaCo", - position: "", - location: "", - period: "2015-2020", - description: "", - hidden: false, - website: { url: "", label: "", inlineLink: false }, - roles: [ - { position: "Engineer", period: "" }, - { position: "Lead", period: "" }, - ], - }, - // Experience with empty position and one role → "1 role" - { - id: "x3", - company: "Gamma", - position: "", - location: "", - period: "2014", - description: "", - hidden: false, - website: { url: "", label: "", inlineLink: false }, - roles: [{ position: "Intern", period: "" }], - }, -]); - -type SectionBaseProps = { - children: React.ReactNode; -}; - -type SectionAddItemButtonProps = { - children: React.ReactNode; -}; - -type SectionItemProps = { - title: string; - subtitle: string; -}; - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) => - selector({ - data: { - sections: { - education: { title: "Education", columns: 1, hidden: false, items: educationItems }, - experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems }, - }, - }, - } as unknown as Resume), - useUpdateResumeData: () => vi.fn(), -})); -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children }: SectionBaseProps) =>
{children}
, -})); -vi.mock("../shared/section-item", () => ({ - SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => , - SectionItem: ({ title, subtitle }: SectionItemProps) => ( -
- {title} - {subtitle} -
- ), -})); - -const { ItemsSection } = await import("../shared/items-section"); -const EducationSectionBuilder = () => ; -const ExperienceSectionBuilder = () => ; - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -const wrap = (ui: React.ReactNode) => {ui}; - -describe("EducationSectionBuilder", () => { - it("maps school → title and degree → subtitle", () => { - render(wrap()); - expect(screen.getByTestId("item-title").textContent).toBe("MIT"); - expect(screen.getByTestId("item-subtitle").textContent).toBe("BS"); - }); - - it("renders the Add a new education affordance", () => { - render(wrap()); - expect(screen.getByRole("button", { name: "Add a new education" })).toBeInTheDocument(); - }); -}); - -describe("ExperienceSectionBuilder", () => { - it("uses position as subtitle when present", () => { - render(wrap()); - const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent); - expect(subtitles[0]).toBe("Senior Engineer"); - }); - - it("falls back to 'N roles' when position is empty and multiple roles exist", () => { - render(wrap()); - const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent); - expect(subtitles[1]).toBe("2 roles"); - }); - - it("uses '1 role' (singular) for a single role entry", () => { - render(wrap()); - const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent); - expect(subtitles[2]).toBe("1 role"); - }); - - it("renders the Add a new experience affordance", () => { - render(wrap()); - expect(screen.getByRole("button", { name: "Add a new experience" })).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/field-labels.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/field-labels.test.tsx deleted file mode 100644 index 94a16ade5..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/field-labels.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -// @vitest-environment happy-dom - -import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import type { ReactNode } from "react"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { defaultResumeData } from "@reactive-resume/schema/resume/default"; -import { BasicsSectionBuilder } from "./basics"; -import { PictureSectionBuilder } from "./picture"; - -const state = vi.hoisted(() => ({ data: {} as ResumeData, update: vi.fn(), uploadFile: vi.fn() })); - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentBuilderResumeSelector: (selector: (resume: { data: ResumeData }) => unknown) => selector(state), - useUpdateResumeData: () => state.update, -})); - -vi.mock("@/libs/tanstack-form", async () => { - const { useForm } = await import("@tanstack/react-form"); - return { useAppForm: useForm }; -}); - -vi.mock("@/libs/orpc/client", () => ({ - orpc: { - storage: { - uploadFile: { mutationOptions: () => ({ mutationFn: state.uploadFile }) }, - deleteFile: { mutationOptions: () => ({ mutationFn: vi.fn() }) }, - }, - }, -})); - -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children }: { children: ReactNode }) => children, -})); -vi.mock("./custom-fields", () => ({ CustomFieldsSection: () => null })); -vi.mock("@/components/input/color-picker", () => ({ ColorPicker: () => null })); - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -beforeEach(() => { - state.data = structuredClone(defaultResumeData); - state.update.mockReset(); - state.update.mockImplementation((update: (draft: ResumeData) => void) => update(state.data)); - state.uploadFile.mockReset(); - state.uploadFile.mockResolvedValue({ url: "/uploads/picture.png" }); -}); - -function renderSection(children: ReactNode) { - return render( - - {children} - , - ); -} - -describe("builder field labels", () => { - it("names and focuses the Website input while preserving URL edits", async () => { - const user = userEvent.setup(); - renderSection(); - - const input = screen.getByRole("textbox", { name: "Website" }); - expect(screen.getByLabelText("Website")).toBe(input); - await user.click(screen.getByText("Website", { selector: "label" })); - expect(input).toHaveFocus(); - - await user.type(input, "example.com/profile"); - await waitFor(() => expect(state.data.basics.website.url).toBe("https://example.com/profile")); - }); - - it("names and focuses Picture Size while preserving numeric edits", async () => { - const user = userEvent.setup(); - renderSection(); - - const input = screen.getByRole("spinbutton", { name: "Size" }); - expect(screen.getByLabelText("Size")).toBe(input); - await user.click(screen.getByText("Size", { selector: "label" })); - expect(input).toHaveFocus(); - - fireEvent.change(input, { target: { value: "144" } }); - await waitFor(() => expect(state.data.picture.size).toBe(144)); - }); - - it("persists named fit choices and previews contain without cropping", async () => { - const user = userEvent.setup(); - renderSection(); - - expect(screen.getByRole("group", { name: "Fit" })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Contain" })); - await waitFor(() => expect(state.data.picture.fit).toBe("contain")); - - const file = new File(["full-image"], "full.png", { type: "image/png" }); - await user.upload(screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement, file); - - await waitFor(() => expect(state.uploadFile).toHaveBeenCalledOnce()); - expect(state.uploadFile.mock.calls[0]?.[0]).toBe(file); - const preview = screen.getByRole("button", { name: "Delete picture" }).querySelector("img"); - expect(preview).toBeInTheDocument(); - expect(getComputedStyle(preview as HTMLImageElement).objectFit).toBe("contain"); - expect(screen.queryByRole("dialog", { name: "Crop picture" })).not.toBeInTheDocument(); - }); - - it("keeps cover uploads in cancelable crop flow", async () => { - const user = userEvent.setup(); - renderSection(); - - const file = new File(["crop-image"], "crop.png", { type: "image/png" }); - await user.upload(screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement, file); - const dialog = screen.getByRole("dialog", { name: "Crop picture" }); - expect(dialog).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Cancel" })); - expect(dialog).not.toBeInTheDocument(); - expect(state.uploadFile).not.toHaveBeenCalled(); - }); - - it("uploads the original file when cropping is skipped", async () => { - const user = userEvent.setup(); - renderSection(); - - const file = new File(["original-image"], "original.png", { type: "image/png" }); - await user.upload(screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement, file); - - const dialog = screen.getByRole("dialog", { name: "Crop picture" }); - await user.click(within(dialog).getByRole("button", { name: "Skip and Upload" })); - - await waitFor(() => expect(state.uploadFile).toHaveBeenCalledOnce()); - expect(state.uploadFile.mock.calls[0]?.[0]).toBe(file); - expect(screen.queryByRole("dialog", { name: "Crop picture" })).not.toBeInTheDocument(); - }); - - it("retries the same full contain file after an upload error", async () => { - state.uploadFile.mockRejectedValueOnce(new Error("Upload failed")); - const user = userEvent.setup(); - renderSection(); - - await user.click(screen.getByRole("button", { name: "Contain" })); - const file = new File(["full-image"], "full.png", { type: "image/png" }); - const input = screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement; - await user.upload(input, file); - - await waitFor(() => expect(state.uploadFile).toHaveBeenCalledOnce()); - expect(state.data.picture.url).toBe(""); - expect(screen.queryByRole("dialog", { name: "Crop picture" })).not.toBeInTheDocument(); - await waitFor(() => expect(input.files).toHaveLength(0)); - - await user.upload(input, file); - await waitFor(() => expect(state.uploadFile).toHaveBeenCalledTimes(2)); - expect(state.uploadFile.mock.calls[0]?.[0]).toBe(file); - expect(state.uploadFile.mock.calls[1]?.[0]).toBe(file); - }); - - it("disables fit and upload controls inside the builder lock fieldset", () => { - renderSection( -
- -
, - ); - - expect(screen.getByRole("button", { name: "Contain" })).toBeDisabled(); - expect(screen.getAllByLabelText("Upload picture")[0]).toBeDisabled(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/many-sections.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/many-sections.test.tsx deleted file mode 100644 index a2a73042b..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/many-sections.test.tsx +++ /dev/null @@ -1,205 +0,0 @@ -// @vitest-environment happy-dom - -// -// Bulk-cover the small reorderable section components — they all share the same -// shape: render a SectionItem per data row with title/subtitle mapped to specific -// fields, plus an "Add a new X" button. Test them together to amortize the mock setup. - -import type { Resume } from "@/features/resume/builder/draft"; -import { render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -const sections = vi.hoisted(() => ({ - awards: [ - { - id: "a1", - title: "Hackathon Winner", - awarder: "Acme", - date: "2024", - hidden: false, - description: "", - website: { url: "", label: "", inlineLink: false }, - }, - ], - certifications: [ - { - id: "c1", - title: "AWS Solutions Architect", - issuer: "AWS", - date: "2024", - hidden: false, - description: "", - website: { url: "", label: "", inlineLink: false }, - }, - ], - interests: [{ id: "i1", name: "Cooking", keywords: [], hidden: false, icon: "", iconColor: "" }], - languages: [{ id: "l1", language: "English", fluency: "Native", level: 5, hidden: false }], - publications: [ - { - id: "p1", - title: "On Type Systems", - publisher: "ACM", - date: "2024", - hidden: false, - description: "", - website: { url: "", label: "", inlineLink: false }, - }, - ], - references: [ - { - id: "r1", - name: "Bob Smith", - position: "Manager", - phone: "", - hidden: false, - description: "", - website: { url: "", label: "", inlineLink: false }, - }, - ], - volunteer: [ - { - id: "v1", - organization: "Code for Good", - position: "Mentor", - location: "Berlin", - period: "2022", - hidden: false, - description: "", - website: { url: "", label: "", inlineLink: false }, - summary: "", - }, - ], -})); - -type SectionBaseProps = { - children: React.ReactNode; - className?: string; -}; - -type SectionAddItemButtonProps = { - children: React.ReactNode; -}; - -type SectionItemProps = { - title: string; - subtitle: string; -}; - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) => - selector({ - data: { - sections: { - awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards }, - certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications }, - interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests }, - languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages }, - publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications }, - references: { title: "References", columns: 1, hidden: false, items: sections.references }, - volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer }, - }, - }, - } as unknown as Resume), - useUpdateResumeData: () => vi.fn(), -})); -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children, className }: SectionBaseProps) => ( -
- {children} -
- ), -})); -vi.mock("../shared/section-item", () => ({ - SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => ( - - ), - SectionItem: ({ title, subtitle }: SectionItemProps) => ( -
- {title} - {subtitle} -
- ), -})); - -const { ItemsSection } = await import("../shared/items-section"); -const AwardsSectionBuilder = () => ; -const CertificationsSectionBuilder = () => ; -const InterestsSectionBuilder = () => ; -const LanguagesSectionBuilder = () => ; -const PublicationsSectionBuilder = () => ; -const ReferencesSectionBuilder = () => ; -const VolunteerSectionBuilder = () => ; - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -const wrap = (ui: React.ReactNode) => {ui}; - -describe("left sidebar section builders — title/subtitle mapping", () => { - const cases = [ - { - name: "awards", - Component: AwardsSectionBuilder, - title: "Hackathon Winner", - subtitle: "Acme", - addCopy: "Add a new award", - }, - { - name: "certifications", - Component: CertificationsSectionBuilder, - title: "AWS Solutions Architect", - subtitle: "AWS • 2024", - addCopy: "Add a new certification", - }, - { - name: "interests", - Component: InterestsSectionBuilder, - title: "Cooking", - subtitle: "", - addCopy: "Add a new interest", - }, - { - name: "languages", - Component: LanguagesSectionBuilder, - title: "English", - subtitle: "Native", - addCopy: "Add a new language", - }, - { - name: "publications", - Component: PublicationsSectionBuilder, - title: "On Type Systems", - subtitle: "ACM", - addCopy: "Add a new publication", - }, - { - name: "references", - Component: ReferencesSectionBuilder, - title: "Bob Smith", - subtitle: "", - addCopy: "Add a new reference", - }, - { - name: "volunteer", - Component: VolunteerSectionBuilder, - title: "Code for Good", - subtitle: "Berlin", - addCopy: "Add a new volunteer experience", - }, - ] as const; - - for (const { name, Component, title, subtitle, addCopy } of cases) { - it(`${name}: maps fields and renders the add button`, () => { - const { unmount } = render(wrap()); - expect(screen.getByTestId("item-title").textContent).toBe(title); - expect(screen.getByTestId("item-subtitle").textContent ?? "").toBe(subtitle); - expect(screen.getByRole("button", { name: addCopy })).toBeInTheDocument(); - unmount(); - }); - } -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.test.tsx deleted file mode 100644 index a185ad38b..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -// @vitest-environment happy-dom - -import type { Resume } from "@/features/resume/builder/draft"; -import { render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -const sectionItems = vi.hoisted(() => [ - { - id: "p1", - hidden: false, - network: "GitHub", - username: "jane", - icon: "github", - iconColor: "", - website: { url: "", label: "", inlineLink: false }, - }, - { - id: "p2", - hidden: false, - network: "LinkedIn", - username: "jane-doe", - icon: "linkedin", - iconColor: "", - website: { url: "", label: "", inlineLink: false }, - }, -]); - -type SectionBaseProps = { - children: React.ReactNode; - className?: string; -}; - -type SectionAddItemButtonProps = { - children: React.ReactNode; -}; - -type SectionItemProps = { - title: string; - subtitle: string; -}; - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) => - selector({ - data: { - sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } }, - }, - } as unknown as Resume), - useUpdateResumeData: () => vi.fn(), -})); -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children, className }: SectionBaseProps) => ( -
- {children} -
- ), -})); -vi.mock("../shared/section-item", () => ({ - SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => , - SectionItem: ({ title, subtitle }: SectionItemProps) => ( -
- {title} - {subtitle} -
- ), -})); - -const { ItemsSection } = await import("../shared/items-section"); -const ProfilesSectionBuilder = () => ; - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -describe("ProfilesSectionBuilder", () => { - it("renders one SectionItem per profile with network as title and username as subtitle", () => { - render( - - - , - ); - - const titles = screen.getAllByTestId("item-title").map((el) => el.textContent); - expect(titles).toEqual(["GitHub", "LinkedIn"]); - - const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent); - expect(subtitles).toEqual(["jane", "jane-doe"]); - }); - - it("renders the 'Add a new profile' affordance", () => { - render( - - - , - ); - expect(screen.getByRole("button", { name: "Add a new profile" })).toBeInTheDocument(); - }); - - it("uses non-dashed border when items are present", () => { - render( - - - , - ); - const wrapper = screen.getByTestId("section-base"); - expect(wrapper.className).toContain("border"); - expect(wrapper.className).not.toContain("border-dashed"); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.test.tsx deleted file mode 100644 index 245e03638..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// @vitest-environment happy-dom - -import type { Resume } from "@/features/resume/builder/draft"; -import { render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -const items = vi.hoisted(() => [ - // Both period and website label present → "2024 • Site" - { - id: "p1", - name: "Open CLI", - period: "2024", - hidden: false, - description: "", - website: { url: "https://example.com", label: "Site", inlineLink: false }, - }, - // Only period - { - id: "p2", - name: "Plugin", - period: "2023", - hidden: false, - description: "", - website: { url: "", label: "", inlineLink: false }, - }, - // No period, no label - { - id: "p3", - name: "Drafts", - period: "", - hidden: false, - description: "", - website: { url: "", label: " ", inlineLink: false }, - }, -]); - -type SectionBaseProps = { - children: React.ReactNode; -}; - -type SectionAddItemButtonProps = { - children: React.ReactNode; -}; - -type SectionItemProps = { - title: string; - subtitle?: string; -}; - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) => - selector({ - data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } }, - } as unknown as Resume), - useUpdateResumeData: () => vi.fn(), -})); -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children }: SectionBaseProps) =>
{children}
, -})); -vi.mock("../shared/section-item", () => ({ - SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => , - SectionItem: ({ title, subtitle }: SectionItemProps) => ( -
- {title} - {subtitle ?? ""} -
- ), -})); - -const { ItemsSection } = await import("../shared/items-section"); -const ProjectsSectionBuilder = () => ; - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -describe("ProjectsSectionBuilder buildSubtitle", () => { - it("joins period and website label with ' • ' when both are present", () => { - render( - - - , - ); - - const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent); - expect(subtitles[0]).toBe("2024 • Site"); - }); - - it("uses just the period when no website label is set", () => { - render( - - - , - ); - const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent); - expect(subtitles[1]).toBe("2023"); - }); - - it("returns undefined when neither period nor a non-blank website label is provided", () => { - render( - - - , - ); - const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent); - // Mock stub renders the literal string "" when subtitle was undefined. - expect(subtitles[2]).toBe(""); - }); - - it("renders the Add a new project affordance", () => { - render( - - - , - ); - expect(screen.getByRole("button", { name: "Add a new project" })).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.test.tsx deleted file mode 100644 index e0151decf..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// @vitest-environment happy-dom - -import type { Resume } from "@/features/resume/builder/draft"; -import { render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -const sectionItems = vi.hoisted(() => [ - { id: "s1", name: "TypeScript", proficiency: "Expert", level: 5, keywords: [], description: "", hidden: false }, - { id: "s2", name: "Go", proficiency: "Intermediate", level: 3, keywords: [], description: "", hidden: false }, -]); - -type SectionBaseProps = { - children: React.ReactNode; - className?: string; -}; - -type SectionAddItemButtonProps = { - children: React.ReactNode; -}; - -type SectionItemProps = { - title: string; - subtitle: string; -}; - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) => - selector({ - data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } }, - } as unknown as Resume), - useUpdateResumeData: () => vi.fn(), -})); -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children, className }: SectionBaseProps) => ( -
- {children} -
- ), -})); -vi.mock("../shared/section-item", () => ({ - SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => , - SectionItem: ({ title, subtitle }: SectionItemProps) => ( -
- {title} - {subtitle} -
- ), -})); - -const { ItemsSection } = await import("../shared/items-section"); -const SkillsSectionBuilder = () => ; - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -describe("SkillsSectionBuilder", () => { - it("renders one SectionItem per skill, mapping name → title and proficiency → subtitle", () => { - render( - - - , - ); - - expect(screen.getAllByTestId("item-title").map((el) => el.textContent)).toEqual(["TypeScript", "Go"]); - expect(screen.getAllByTestId("item-subtitle").map((el) => el.textContent)).toEqual(["Expert", "Intermediate"]); - }); - - it("renders an Add a new skill affordance", () => { - render( - - - , - ); - expect(screen.getByRole("button", { name: "Add a new skill" })).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/summary.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/summary.tsx deleted file mode 100644 index 267f9edba..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/summary.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { RichInput } from "@/components/input/rich-input"; -import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { SectionBase } from "../shared/section-base"; - -export function SummarySectionBuilder() { - const section = useCurrentBuilderResumeSelector((resume) => resume.data.summary); - const updateResumeData = useUpdateResumeData(); - - const onChange = (value: string) => { - updateResumeData((draft) => { - draft.summary.content = value; - }); - }; - - return ( - - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/items-section.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/items-section.tsx deleted file mode 100644 index cb491508c..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/items-section.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import type { - CustomSectionItem, - CustomSectionType, - SectionItem as SectionItemType, - SectionType, -} from "@reactive-resume/schema/resume/data"; -import type { ReactNode } from "react"; -import { plural } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { AnimatePresence, Reorder } from "motion/react"; -import { cn } from "@reactive-resume/utils/style"; -import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { SectionBase } from "./section-base"; -import { SectionAddItemButton, SectionItem } from "./section-item"; - -type SectionItemListProps = { - type: CustomSectionType; - items: T[]; - onReorder: (items: T[]) => void; - getTitle: (item: T) => string; - getSubtitle?: (item: T) => string | undefined; - customSectionId?: string; -}; - -export function SectionItemList({ - type, - items, - onReorder, - getTitle, - getSubtitle, - customSectionId, -}: SectionItemListProps) { - return ( - // popLayout pops exiting rows out of flow, so the group must be the positioned ancestor. - - - {items.map((item) => ( - - ))} - - - ); -} - -type ItemsSectionConfig = { - title: (item: SectionItemType) => string; - subtitle?: (item: SectionItemType) => string | undefined; - addLabel: ReactNode; -}; - -export const SECTIONS: { [T in SectionType]: ItemsSectionConfig } = { - profiles: { - title: (item) => item.network, - subtitle: (item) => item.username, - addLabel: Add a new profile, - }, - experience: { - title: (item) => item.company, - subtitle: (item) => item.position || plural(item.roles.length, { one: "# role", other: "# roles" }), - addLabel: Add a new experience, - }, - education: { - title: (item) => item.school, - subtitle: (item) => item.degree, - addLabel: Add a new education, - }, - projects: { - title: (item) => item.name, - subtitle: (item) => [item.period, item.website.label].filter((part) => part?.trim()).join(" • ") || undefined, - addLabel: Add a new project, - }, - skills: { - title: (item) => item.name, - subtitle: (item) => item.proficiency, - addLabel: Add a new skill, - }, - languages: { - title: (item) => item.language, - subtitle: (item) => item.fluency, - addLabel: Add a new language, - }, - interests: { - title: (item) => item.name, - addLabel: Add a new interest, - }, - awards: { - title: (item) => item.title, - subtitle: (item) => item.awarder, - addLabel: Add a new award, - }, - certifications: { - title: (item) => item.title, - subtitle: (item) => [item.issuer, item.date].filter(Boolean).join(" • ") || undefined, - addLabel: Add a new certification, - }, - publications: { - title: (item) => item.title, - subtitle: (item) => item.publisher, - addLabel: Add a new publication, - }, - volunteer: { - title: (item) => item.organization, - subtitle: (item) => item.location, - addLabel: Add a new volunteer experience, - }, - references: { - title: (item) => item.name, - addLabel: Add a new reference, - }, -}; - -type ItemsSectionProps = { - type: T; -}; - -export function ItemsSection({ type }: ItemsSectionProps) { - const items = useCurrentBuilderResumeSelector((resume) => resume.data.sections[type].items) as SectionItemType[]; - const updateResumeData = useUpdateResumeData(); - const config = SECTIONS[type]; - - const handleReorder = (nextItems: SectionItemType[]) => { - updateResumeData((draft) => { - (draft.sections[type] as { items: SectionItemType[] }).items = nextItems; - }); - }; - - return ( - - - - {config.addLabel} - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-base.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-base.tsx deleted file mode 100644 index d9be4ebe9..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-base.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { SectionType } from "@reactive-resume/schema/resume/data"; -import type { LeftSidebarSection } from "@/libs/resume/section"; -import { t } from "@lingui/core/macro"; -import { CaretDownIcon } from "@phosphor-icons/react"; -import { getDefaultSectionIconName } from "@reactive-resume/schema/resume/section-icons"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion"; -import { Button } from "@reactive-resume/ui/components/button"; -import { cn } from "@reactive-resume/utils/style"; -import { IconPicker } from "@/components/input/icon-picker"; -import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { getSectionIcon, getSectionTitle } from "@/libs/resume/section"; -import { useSectionStore } from "../../../-store/section"; -import { SectionDropdownMenu } from "./section-menu"; - -type Props = React.ComponentProps & { - type: LeftSidebarSection; -}; - -export function SectionBase({ type, className, ...props }: Props) { - const updateResumeData = useUpdateResumeData(); - // Subscribe to only this section's slice, not the whole resume. Otherwise editing any field - // (which replaces the resume reference) re-renders all ~15 section wrappers on every keystroke. - // Immer keeps untouched slices reference-stable, so Zustand bails out of the unrelated sections. - const section = useCurrentBuilderResumeSelector((resume) => { - const data = resume.data; - return type === "basics" - ? data.basics - : type === "summary" - ? data.summary - : type === "picture" - ? data.picture - : type === "custom" - ? data.customSections - : data.sections[type]; - }); - - const isHidden = "hidden" in section && section.hidden; - const hasSectionIcon = !["picture", "basics", "custom"].includes(type); - const rawIcon = "icon" in section && typeof section.icon === "string" ? section.icon : ""; - const fallbackIcon = hasSectionIcon ? getDefaultSectionIconName(type as "summary" | SectionType) : ""; - const sectionIcon = rawIcon === "none" ? "" : rawIcon || fallbackIcon; - - const sectionTitle = ("title" in section && section.title) || getSectionTitle(type); - - const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false); - const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed); - - const onIconChange = (icon: string) => { - // Store "none" when user explicitly picks the empty/prohibit option - const valueToStore = icon === "" ? "none" : icon; - - updateResumeData((draft) => { - if (type === "summary") { - draft.summary.icon = valueToStore; - } else if (type !== "basics" && type !== "picture" && type !== "custom") { - draft.sections[type as SectionType].icon = valueToStore; - } - }); - }; - - return ( - toggleCollapsed(type)} - className={cn("space-y-4", isHidden && "opacity-50")} - > - -
- - - - } - /> - -
- {hasSectionIcon ? ( - - ) : ( - getSectionIcon(type) - )} -

{sectionTitle}

-
- - {!["picture", "basics", "custom"].includes(type) && ( - - )} -
- - -
-
- ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx deleted file mode 100644 index 3a8c2c3e9..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import type { - CustomSectionItem, - CustomSectionType, - ResumeData, - SectionItem as SectionItemType, - SectionType, -} from "@reactive-resume/schema/resume/data"; -import type { ButtonProps } from "@reactive-resume/ui/components/button"; -import type { Ref } from "react"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { - ArrowBendUpRightIcon, - CopySimpleIcon, - DotsSixVerticalIcon, - DotsThreeVerticalIcon, - EyeClosedIcon, - EyeIcon, - FileIcon, - FolderPlusIcon, - PencilSimpleLineIcon, - PlusCircleIcon, - PlusIcon, - TrashSimpleIcon, -} from "@phosphor-icons/react"; -import { Reorder, useDragControls } from "motion/react"; -import { useMemo } from "react"; -import { Button } from "@reactive-resume/ui/components/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@reactive-resume/ui/components/dropdown-menu"; -import { cn } from "@reactive-resume/utils/style"; -import { useDialogStore } from "@/dialogs/store"; -import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useConfirm } from "@/hooks/use-confirm"; -import { EASE_OUT_STRONG } from "@/libs/motion"; -import { atsFindingItemElementId } from "@/libs/resume/ats"; -import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item"; - -// ============================================================================ -// MoveItemSubmenu Component -// ============================================================================ - -type MoveItemSubmenuProps = { - type: CustomSectionType; - item: CustomSectionItem | SectionItemType; - customSectionId?: string; -}; - -/** - * Submenu component for moving items between sections/pages. - * Displays compatible targets grouped by page with options to: - * - Move to existing compatible section - * - Create new section on existing page - * - Create new page with new section - */ -function MoveItemSubmenu({ type, item, customSectionId }: MoveItemSubmenuProps) { - const resume = useCurrentResume(); - const updateResumeData = useUpdateResumeData(); - - /** Compute compatible move targets grouped by page */ - const moveTargets = useMemo( - () => getCompatibleMoveTargets(resume.data, type, customSectionId), - [resume, type, customSectionId], - ); - - /** Get the current section's title (used when creating new sections) */ - const currentSectionTitle = useMemo( - () => getSourceSectionTitle(resume.data, type, customSectionId), - [resume, type, customSectionId], - ); - - /** Handler: Move item to an existing section */ - const handleMoveToSection = (targetSectionId: string) => { - updateResumeData((draft) => { - moveItem(draft, { - itemId: item.id, - type, - customSectionId, - target: { type: "section", sectionId: targetSectionId }, - }); - }); - }; - - /** Handler: Create a new custom section on an existing page and move the item there */ - const handleNewSectionOnPage = (pageIndex: number) => { - updateResumeData((draft) => { - moveItem(draft, { - itemId: item.id, - type, - customSectionId, - target: { type: "new-section", title: currentSectionTitle, pageIndex }, - }); - }); - }; - - /** Handler: Create a new page with a new custom section and move the item there */ - const handleNewPage = () => { - updateResumeData((draft) => { - moveItem(draft, { - itemId: item.id, - type, - customSectionId, - target: { type: "new-page", title: currentSectionTitle }, - }); - }); - }; - - return ( - - - - Move to - - - - {/* Render each page as a submenu */} - {moveTargets.map(({ pageIndex, sections }) => ( - - - - Page {pageIndex + 1} - - - - {/* Existing compatible sections on this page */} - {sections.map(({ sectionId, sectionTitle }) => ( - handleMoveToSection(sectionId)}> - {sectionTitle} - - ))} - - {/* Separator if there are existing sections */} - {sections.length > 0 && } - - {/* Option to create a new section on this page */} - handleNewSectionOnPage(pageIndex)}> - - New Section - - - - ))} - - - - {/* Option to create a new page with a new section */} - - - New Page - - - - ); -} - -// ============================================================================ -// SectionItem Component -// ============================================================================ - -type Props = { - type: CustomSectionType; - item: T; - title: string; - subtitle?: string; - customSectionId?: string; - // Injected by `AnimatePresence mode="popLayout"` to pop an exiting row out of the flow. - ref?: Ref; -}; - -export function SectionItem({ - type, - item, - title, - subtitle, - customSectionId, - ref, -}: Props) { - const confirm = useConfirm(); - const controls = useDragControls(); - const { openDialog } = useDialogStore(); - const updateResumeData = useUpdateResumeData(); - - // Returns the items array (and index) this row lives in, from either a custom or a built-in section. - const findItem = (draft: ResumeData) => { - const items = customSectionId - ? draft.customSections.find((section) => section.id === customSectionId)?.items - : // Type assertion: when customSectionId is not provided, type is always a built-in SectionType - draft.sections[type as SectionType].items; - const index = items?.findIndex((_item) => _item.id === item.id) ?? -1; - return index === -1 || !items ? null : { items, index }; - }; - - const onToggleVisibility = () => { - updateResumeData((draft) => { - const found = findItem(draft); - if (found) found.items[found.index].hidden = !found.items[found.index].hidden; - }); - }; - - const onUpdate = () => { - // Type assertion needed because TypeScript can't narrow the union type through template literals - openDialog(`resume.sections.${type}.update`, { item, customSectionId } as never); - }; - - const onDuplicate = () => { - // Type assertion needed because TypeScript can't narrow the union type through template literals - openDialog(`resume.sections.${type}.create`, { item, customSectionId } as never); - }; - - const onDelete = async () => { - const confirmed = await confirm(t`Are you sure you want to delete this item?`, { - confirmText: t({ - comment: "Destructive confirmation button label when deleting a section item in resume builder", - message: "Delete", - }), - cancelText: t({ - comment: "Confirmation dialog button label to abort deleting a section item in resume builder", - message: "Cancel", - }), - }); - - if (!confirmed) return; - - updateResumeData((draft) => { - const found = findItem(draft); - if (found) found.items.splice(found.index, 1); - }); - }; - - return ( - -
{ - e.preventDefault(); - controls.start(e); - }} - > - -
- - - - - - - - - - - - {item.hidden ? : } - {item.hidden ? Show : Hide} - - - - - - - - - Update - - - - - Duplicate - - - - - - - - - - - Delete - - - - -
- ); -} - -type AddButtonProps = Omit & { - type: CustomSectionType | "custom"; - customSectionId?: string; -}; - -export function SectionAddItemButton({ type, customSectionId, className, children, ...props }: AddButtonProps) { - const { openDialog } = useDialogStore(); - - const handleAdd = () => { - if (type === "custom") { - openDialog("resume.sections.custom.create", undefined); - } else { - openDialog(`resume.sections.${type}.create`, customSectionId ? { customSectionId } : undefined); - } - }; - - return ( - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx deleted file mode 100644 index 03779836c..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx +++ /dev/null @@ -1,263 +0,0 @@ -// @vitest-environment happy-dom - -import type { Resume } from "@/features/resume/builder/draft"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { SectionDropdownMenu } from "./section-menu"; - -const mocks = vi.hoisted(() => ({ - updateResumeData: vi.fn(), - toastAdd: vi.fn(), - resume: { value: {} as Resume }, -})); - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentResume: () => mocks.resume.value, - useUpdateResumeData: () => mocks.updateResumeData, -})); - -vi.mock("@reactive-resume/ui/components/toast", () => ({ - toast: { add: mocks.toastAdd }, -})); - -vi.mock("@/hooks/use-confirm", () => ({ - useConfirm: () => vi.fn(), -})); - -vi.mock("@/hooks/use-prompt", () => ({ - usePrompt: () => vi.fn(), -})); - -vi.mock("@/dialogs/store", () => ({ - useDialogStore: () => ({ - openDialog: vi.fn(), - }), -})); - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -beforeEach(() => { - mocks.updateResumeData.mockClear(); - mocks.toastAdd.mockClear(); - mocks.resume.value = { - isLocked: false, - data: { - metadata: { page: { locale: "en-US" } }, - sections: { - skills: { - title: "Skills", - columns: 2, - hidden: false, - layout: "default", - items: [], - }, - experience: { - title: "Experience", - columns: 1, - hidden: false, - items: [ - { id: "unknown", company: "Mystery Co", period: "Recently" }, - { id: "older", company: "Older Co", period: "2018 - 2020" }, - { id: "current", company: "Current Co", period: "2023 - Present" }, - ], - }, - education: { - title: "Education", - columns: 1, - hidden: false, - items: [ - { id: "earlier", school: "Earlier School", period: "2016 - 2020" }, - { id: "later", school: "Later School", period: "2020 - 2024" }, - ], - }, - }, - }, - } as unknown as Resume; -}); - -function openSectionOptions() { - fireEvent.click(screen.getByRole("button", { name: "Section options" })); - return screen.findByRole("menu"); -} - -describe("SkillsSectionDropdownMenu", () => { - it("selects keyword bullets without changing item layout or columns", async () => { - render( - - - , - ); - screen.getByRole("button", { name: "Section options" }).click(); - (await screen.findByRole("menuitem", { name: "Keyword layout" })).click(); - (await screen.findByRole("menuitemradio", { name: "Bulleted list" })).click(); - const draft = { sections: { skills: { layout: "default", columns: 2, keywordLayout: "inline", items: [] } } }; - mocks.updateResumeData.mock.calls[0][0](draft); - expect(draft.sections.skills).toEqual({ layout: "default", columns: 2, keywordLayout: "list", items: [] }); - }); - - it("updates resume data correctly when selecting 'inline' for skills section", async () => { - render( - - - , - ); - - await openSectionOptions(); - - (await screen.findByRole("menuitem", { name: /columns/i })).click(); - - (await screen.findByRole("menuitemradio", { name: /inline/i })).click(); - - expect(mocks.updateResumeData).toHaveBeenCalledTimes(1); - expect(mocks.updateResumeData).toHaveBeenCalledWith(expect.any(Function)); - - const mutation = mocks.updateResumeData.mock.calls[0][0]; - const mockDraft = { - sections: { - skills: { - title: "Skills", - columns: 2, - hidden: false, - layout: "default", - items: [], - }, - }, - }; - - mutation(mockDraft); - - expect(mockDraft.sections.skills.layout).toBe("inline"); - expect(mockDraft.sections.skills.columns).toBe(1); - }); - - it("updates resume data correctly when selecting numeric columns for skills section", async () => { - render( - - - , - ); - - await openSectionOptions(); - - (await screen.findByRole("menuitem", { name: /columns/i })).click(); - - (await screen.findByRole("menuitemradio", { name: /3 columns/i })).click(); - - expect(mocks.updateResumeData).toHaveBeenCalledTimes(1); - expect(mocks.updateResumeData).toHaveBeenCalledWith(expect.any(Function)); - - const mutation = mocks.updateResumeData.mock.calls[0][0]; - const mockDraft = { - sections: { - skills: { - title: "Skills", - columns: 2, - hidden: false, - layout: "default", - items: [], - }, - }, - }; - - mutation(mockDraft); - - expect(mockDraft.sections.skills.layout).toBe("default"); - expect(mockDraft.sections.skills.columns).toBe(3); - }); -}); - -describe("heading visibility", () => { - it("toggles heading visibility without changing section visibility", async () => { - render( - - - , - ); - - await openSectionOptions(); - fireEvent.click(await screen.findByRole("menuitem", { name: "Hide heading" })); - - expect(mocks.updateResumeData).toHaveBeenCalledTimes(1); - const mutation = mocks.updateResumeData.mock.calls[0]?.[0] as (draft: Resume["data"]) => void; - const draft = structuredClone(mocks.resume.value.data); - mutation(draft); - - expect(draft.sections.experience.showHeading).toBe(false); - expect(draft.sections.experience.hidden).toBe(false); - }); -}); - -describe("chronological section sorting", () => { - it("sorts Experience once through one draft mutation and names only unresolved items", async () => { - render( - - - , - ); - - await openSectionOptions(); - fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by date" })); - - expect(mocks.updateResumeData).toHaveBeenCalledTimes(1); - const mutation = mocks.updateResumeData.mock.calls[0]?.[0] as (draft: Resume["data"]) => void; - const draft = structuredClone(mocks.resume.value.data); - mutation(draft); - - expect(draft.sections.experience.items.map(({ id }) => id)).toEqual(["current", "older", "unknown"]); - expect(mocks.toastAdd).toHaveBeenCalledWith({ - type: "warning", - description: "Could not sort these items; they stayed at the end: Mystery Co.", - }); - }); - - it("sorts Education once without showing an unresolved notice", async () => { - render( - - - , - ); - - await openSectionOptions(); - fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by date" })); - - expect(mocks.updateResumeData).toHaveBeenCalledTimes(1); - const mutation = mocks.updateResumeData.mock.calls[0]?.[0] as (draft: Resume["data"]) => void; - const draft = structuredClone(mocks.resume.value.data); - mutation(draft); - - expect(draft.sections.education.items.map(({ id }) => id)).toEqual(["later", "earlier"]); - expect(mocks.toastAdd).not.toHaveBeenCalled(); - }); - - it("disables sorting when the resume is locked", async () => { - mocks.resume.value = { ...mocks.resume.value, isLocked: true }; - render( - - - , - ); - - await openSectionOptions(); - const sortItem = await screen.findByRole("menuitem", { name: "Sort by date" }); - - expect(sortItem).toHaveAttribute("aria-disabled", "true"); - fireEvent.keyDown(sortItem, { key: "Escape" }); - expect(mocks.updateResumeData).not.toHaveBeenCalled(); - }); - - it("does not offer date sorting for other built-in sections", async () => { - render( - - - , - ); - - await openSectionOptions(); - - expect(screen.queryByRole("menuitem", { name: "Sort by date" })).not.toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx deleted file mode 100644 index 735598bab..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import type { SectionType } from "@reactive-resume/schema/resume/data"; -import { t } from "@lingui/core/macro"; -import { Plural, Trans } from "@lingui/react/macro"; -import { - BroomIcon, - ColumnsIcon, - EyeClosedIcon, - EyeIcon, - ListIcon, - PencilSimpleLineIcon, - PlusIcon, - SortDescendingIcon, -} from "@phosphor-icons/react"; -import { sortSectionItemsByPeriod } from "@reactive-resume/resume/section-sort"; -import { Button } from "@reactive-resume/ui/components/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@reactive-resume/ui/components/dropdown-menu"; -import { toast } from "@reactive-resume/ui/components/toast"; -import { useDialogStore } from "@/dialogs/store"; -import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { useConfirm } from "@/hooks/use-confirm"; -import { usePrompt } from "@/hooks/use-prompt"; -import { SkillKeywordLayoutMenu } from "./skill-keyword-layout-menu"; - -type Props = { - type: "summary" | SectionType; -}; - -export function SectionDropdownMenu({ type }: Props) { - const prompt = usePrompt(); - const confirm = useConfirm(); - const { openDialog } = useDialogStore(); - - const updateResumeData = useUpdateResumeData(); - const resume = useCurrentResume(); - const section = type === "summary" ? resume.data.summary : resume.data.sections[type]; - const showHeading = section.showHeading !== false; - const dropDownValue = - type === "skills" && resume.data.sections[type].layout === "inline" ? "inline" : section.columns.toString(); - - const onAddItem = () => { - if (type === "summary") return; - openDialog(`resume.sections.${type}.create`, undefined); - }; - - const onSortByDate = () => { - if ((type !== "experience" && type !== "education") || resume.isLocked) return; - - let unresolvedLabels: string[] = []; - if (type === "experience") { - const currentItems = resume.data.sections.experience.items; - const result = sortSectionItemsByPeriod(currentItems, resume.data.metadata.page.locale); - const labelById = new Map(currentItems.map((item) => [item.id, item.company.trim() || item.id])); - unresolvedLabels = result.unresolvedIds.map((id) => labelById.get(id) ?? id); - updateResumeData((draft) => { - draft.sections.experience.items = result.items; - }); - } else { - const currentItems = resume.data.sections.education.items; - const result = sortSectionItemsByPeriod(currentItems, resume.data.metadata.page.locale); - const labelById = new Map(currentItems.map((item) => [item.id, item.school.trim() || item.id])); - unresolvedLabels = result.unresolvedIds.map((id) => labelById.get(id) ?? id); - updateResumeData((draft) => { - draft.sections.education.items = result.items; - }); - } - - if (unresolvedLabels.length > 0) { - toast.add({ - type: "warning", - description: t`Could not sort these items; they stayed at the end: ${unresolvedLabels.join(", ")}.`, - }); - } - }; - - const onToggleVisibility = () => { - updateResumeData((draft) => { - if (type === "summary") { - draft.summary.hidden = !draft.summary.hidden; - } else { - draft.sections[type].hidden = !draft.sections[type].hidden; - } - }); - }; - - const onToggleHeading = () => { - updateResumeData((draft) => { - if (type === "summary") { - draft.summary.showHeading = !(draft.summary.showHeading !== false); - } else { - draft.sections[type].showHeading = !(draft.sections[type].showHeading !== false); - } - }); - }; - - const onRenameSection = async () => { - const newTitle = await prompt(t`What do you want to rename this section to?`, { - description: t`Leave empty to reset the title to the original.`, - defaultValue: section.title, - }); - - if (newTitle === null || newTitle === section.title) return; - - updateResumeData((draft) => { - if (type === "summary") { - draft.summary.title = newTitle ?? ""; - } else { - draft.sections[type].title = newTitle ?? ""; - } - }); - }; - - const onSetColumns = (value: string) => { - updateResumeData((draft) => { - if (type === "skills") { - if (value === "inline") { - draft.sections[type].layout = value; - draft.sections[type].columns = 1; - return; - } - draft.sections[type].layout = "default"; - } - - if (type === "summary") { - draft.summary.columns = Number.parseInt(value, 10); - } else { - draft.sections[type].columns = Number.parseInt(value, 10); - } - }); - }; - - const onReset = async () => { - const confirmed = await confirm(t`Are you sure you want to reset this section?`, { - description: t`This will remove all items from this section.`, - confirmText: t({ - comment: "Destructive confirmation button label when resetting a resume section", - message: "Reset", - }), - cancelText: t({ - comment: "Confirmation dialog button label to abort resetting a resume section", - message: "Cancel", - }), - }); - - if (!confirmed) return; - - updateResumeData((draft) => { - if (type === "summary") { - draft.summary.content = ""; - } else { - draft.sections[type].items = []; - } - }); - }; - - return ( - - - - - } - /> - - - {type !== "summary" && ( - <> - - - - Add a new item - - - {(type === "experience" || type === "education") && ( - - - Sort by date - - )} - - - - - )} - - - - {section.hidden ? : } - {section.hidden ? Show : Hide} - - - - {showHeading ? : } - {showHeading ? Hide heading : Show heading} - - - - - Rename - - - {type === "skills" && } - - - - - Columns - - - - - {[1, 2, 3, 4, 5, 6].map((column) => ( - - - - ))} - - {type === "skills" && ( - <> - - - 1 Column / Inline - - - )} - - - - - - - - - - - Reset - - - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/skill-keyword-layout-menu.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/skill-keyword-layout-menu.test.tsx deleted file mode 100644 index 60d948a84..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/skill-keyword-layout-menu.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -// @vitest-environment happy-dom -import { act, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { defaultResumeData } from "@reactive-resume/schema/resume/default"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@reactive-resume/ui/components/dropdown-menu"; -import { useResumeStore } from "@/features/resume/builder/draft"; -import { SkillKeywordLayoutMenu } from "./skill-keyword-layout-menu"; - -const queryClient = { setQueryData: vi.fn() }; -vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => queryClient })); -vi.mock("@tanstack/react-router", () => ({ useParams: () => ({ resumeId: "keywords" }) })); -vi.mock("@/libs/orpc/client", () => ({ - orpc: { resume: { getById: { queryOptions: () => ({ queryKey: ["resume"] }) } } }, - streamClient: {}, -})); - -beforeEach(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); - const data = structuredClone(defaultResumeData); - data.customSections = [{ ...data.sections.skills, id: "custom", type: "skills", columns: 2 }]; - useResumeStore.getState().initialize({ - id: "keywords", - name: "Keywords", - slug: "keywords", - tags: [], - data, - isLocked: false, - updatedAt: new Date(), - }); -}); -afterEach(() => useResumeStore.getState().reset()); - -function renderMenu(custom: boolean) { - return render( - - - Options - - - - - , - ); -} - -it.each([false, true])("changes keyword presentation through real draft hook with custom=%s", async (custom) => { - renderMenu(custom); - const selected = () => - custom - ? useResumeStore.getState().resume?.data.customSections[0] - : useResumeStore.getState().resume?.data.sections.skills; - screen.getByRole("button", { name: "Options" }).click(); - (await screen.findByRole("menuitem", { name: "Keyword layout" })).click(); - (await screen.findByRole("menuitemradio", { name: "Bulleted list" })).click(); - expect(selected()?.keywordLayout).toBe("list"); - expect(selected()?.columns).toBe(custom ? 2 : 1); - act(() => useResumeStore.getState().undo()); - expect(selected()?.keywordLayout).toBe("inline"); - act(() => useResumeStore.getState().redo()); - expect(selected()?.keywordLayout).toBe("list"); -}); - -it.each([false, true])("disables keyword controls on locked resumes with custom=%s", async (custom) => { - useResumeStore.getState().patchResume((resume) => { - resume.isLocked = true; - }); - renderMenu(custom); - screen.getByRole("button", { name: "Options" }).click(); - const control = await screen.findByRole("menuitem", { name: "Keyword layout" }); - expect(control.getAttribute("aria-disabled")).toBe("true"); - control.click(); - expect(screen.queryByRole("menuitemradio", { name: "Bulleted list" })).toBeNull(); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/skill-keyword-layout-menu.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/skill-keyword-layout-menu.tsx deleted file mode 100644 index f367b20d4..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/skill-keyword-layout-menu.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { ListIcon } from "@phosphor-icons/react"; -import { - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from "@reactive-resume/ui/components/dropdown-menu"; -import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft"; - -type SkillKeywordLayoutMenuProps = { sectionId?: string }; - -export function SkillKeywordLayoutMenu({ sectionId }: SkillKeywordLayoutMenuProps) { - const resume = useCurrentResume(); - const updateResumeData = useUpdateResumeData(); - const section = sectionId - ? resume.data.customSections.find((section) => section.id === sectionId && section.type === "skills") - : resume.data.sections.skills; - if (!section) return null; - - return ( - - - - Keyword layout - - - { - if (value !== "inline" && value !== "list") return; - updateResumeData((draft) => { - const target = sectionId - ? draft.customSections.find((section) => section.id === sectionId && section.type === "skills") - : draft.sections.skills; - if (target) target.keywordLayout = value; - }); - }} - > - - Inline - - - Bulleted list - - - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx index 0c3545e6b..8ec96fd6a 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx @@ -66,6 +66,7 @@ vi.mock("../shared/section-base", () => ({ })); const { AtsCheckSectionBuilder } = await import("./ats-check"); +const { useEditorStore } = await import("@/features/resume/editor/store"); beforeAll(() => { i18n.loadAndActivate({ locale: "en", messages: {} }); @@ -150,7 +151,7 @@ describe("AtsCheckSectionBuilder", () => { expect(screen.getByText("1 note")).toBeTruthy(); }); - it("opens the owning sidebar section when a finding's location is clicked", () => { + it("opens what a finding is about in Write when its location is clicked", () => { resumeState.data = makeResume((data) => { data.basics.email = ""; }); @@ -159,7 +160,7 @@ describe("AtsCheckSectionBuilder", () => { fireEvent.click(screen.getByRole("button", { name: /Basics/ })); expect(editorModeState.setMode).toHaveBeenCalledWith("write"); - expect(sectionState.setCollapsed).toHaveBeenCalledWith("basics", false); + expect(useEditorStore.getState()).toMatchObject({ selection: { kind: "header" }, basicsOpen: true }); }); it("points typography findings at the right sidebar", () => { diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx index 6a8a92c94..de9ef50fc 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx @@ -19,14 +19,9 @@ import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card"; import { AtsPdfReportView } from "@/features/ats-checker/report/report-view"; import { blobToPdfFile, runAtsCheck } from "@/features/ats-checker/run-ats-check"; import { useResumeData } from "@/features/resume/builder/draft"; -import { getScrollBehavior } from "@/features/resume/builder/section-recovery"; +import { getScrollBehavior, revealSelectionInPanel } from "@/features/resume/editor/write/reveal"; import { createResumePdfBlob } from "@/features/resume/export/pdf-document"; -import { - atsFindingItemElementId, - getAtsFindingLocation, - getAtsFindingMessage, - getAtsFindingTarget, -} from "@/libs/resume/ats"; +import { getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "@/libs/resume/ats"; import { useEditorMode } from "../../../-components/use-editor-mode"; import { useSectionStore } from "../../../-store/section"; import { SectionBase } from "../shared/section-base"; @@ -115,14 +110,28 @@ function LiveLintTier() { const target = getAtsFindingTarget(pointer, data); if (!target) return; - // Content findings open Write, design findings open Design; then the field scrolls into view. - setMode(target.side === "left" ? "write" : "design"); - setCollapsed(target.section, false); + // Content findings open the entry in Write; design findings open their section in Design. + if (target.side === "left") { + const customIndex = target.section === "custom" ? Number(pointer.split("/")[2]) : -1; + const sectionId = customIndex >= 0 ? data?.customSections[customIndex]?.id : target.section; + if (!sectionId) return; + setMode("write"); + revealSelectionInPanel( + sectionId === "basics" || sectionId === "picture" + ? { kind: "header" } + : target.itemId + ? { kind: "item", sectionId, itemId: target.itemId } + : { kind: "section", sectionId }, + ); + return; + } + setMode("design"); + setCollapsed(target.section, false); requestAnimationFrame(() => { - const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null; - const destination = item ?? document.getElementById(`sidebar-${target.section}`); - destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() }); + document + .getElementById(`sidebar-${target.section}`) + ?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() }); }); }, [data, setCollapsed, setMode],