-
-
-
-
- );
-}
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 (
+
+
+
+ {open && (
+
+ )}
+
+ );
+}
+
+function PhotoRow({ locked }: { locked: boolean }) {
+ const picture = useCurrentBuilderResumeSelector((resume) => resume.data.picture);
+ const hasPhoto = Boolean(picture.url);
+
+ return (
+
+ {hasPhoto ? (
+
+ ) : (
+
+
+
+ )}
+
+ {!hasPhoto ? (
+ No photo. Most ATS ignore photos; some regions expect one.
+ ) : picture.hidden ? (
+ Photo hidden from the page.
+ ) : (
+ Photo on the page.
+ )}
+