resume-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 3f16b2375723
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 70bace54357d4b57b674108f
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/Editor.tsx
// The resume editor: the form on the left, the paper on the right (an Edit / Preview switch on phones).
// With a letter, a Resume / Cover letter switch edits the cover letter in the same template.
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode, type TextareaHTMLAttributes } from "react";
import {
  blankWork, blankEducation, blankSkill, blankProject, blankLanguage, blankCertificate, blankVolunteer, blankAward, blankPublication, blankInterest,
  isEmptyEntry, sectionTitle, dateRange, fmtDate, degreeOf, colorOf,
  COLORS, COLOR_IDS, CORE_SECTIONS, DEFAULT_TITLES, LIMITS, PAPERS, TEMPLATES,
  type Basics, type ColorId, type ListKey, type Resume, type SectionKey, type Settings,
} from "../functions/lib/resume";
import { layout, layoutLetter, REFLOW_WIDTH, TEMPLATE_NAMES } from "./layout";
import { Paper } from "./Paper";
import { Menu, toast, type MenuItem } from "./ui";
import type { Letter } from "./store";

export type Mode = "resume" | "letter";
export interface EditorError { text: string; field?: "name" }

const icons = {
  up: <path d="m5 12 5-5 5 5" />,
  down: <path d="m5 8 5 5 5-5" />,
  x: <path d="M5.5 5.5l9 9M14.5 5.5l-9 9" />,
  plus: <path d="M10 4v12M4 10h12" />,
  dots: <><circle cx="4.5" cy="10" r=".9" fill="currentColor" /><circle cx="10" cy="10" r=".9" fill="currentColor" /><circle cx="15.5" cy="10" r=".9" fill="currentColor" /></>,
};
function Icon({ name, size = 16 }: { name: keyof typeof icons; size?: number }) {
  return <svg width={size} height={size} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{icons[name]}</svg>;
}
function IconBtn({ icon, label, onClick, disabled }: { icon: keyof typeof icons; label: string; onClick: () => void; disabled?: boolean }) {
  return <button className="btn btn-quiet btn-icon btn-sm icon-tool" type="button" aria-label={label} title={label} onClick={onClick} disabled={disabled}><Icon name={icon} /></button>;
}

function Field({ label, value, onChange, placeholder, type = "text", max = LIMITS.short, autoComplete = "off", error, id }: {
  label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string; max?: number; autoComplete?: string; error?: string; id?: string;
}) {
  return (
    <label className="field">
      <span>{label}</span>
      <input className="input" id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} maxLength={max} autoComplete={autoComplete}
        aria-invalid={error ? true : undefined} aria-describedby={error && id ? `${id}-error` : undefined} />
      {error && <span className="field-error" id={id ? `${id}-error` : undefined}>{error}</span>}
    </label>
  );
}

/** A textarea that grows with its text. */
function Grow({ inputRef, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { value: string; inputRef?: (el: HTMLTextAreaElement | null) => void }) {
  const ref = useRef<HTMLTextAreaElement | null>(null);
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${el.scrollHeight + 2}px`;
  }, [props.value]);
  return <textarea rows={1} {...props} ref={(el) => { ref.current = el; inputRef?.(el); }} />;
}

const moved = <T,>(list: T[], i: number, d: number): T[] => {
  const j = i + d;
  if (j < 0 || j >= list.length) return list;
  const next = [...list];
  [next[i], next[j]] = [next[j], next[i]];
  return next;
};

/** Bullet points: Enter starts a new one, Backspace at the start joins it to the one above, pasted lines become bullets. */
function Bullets({ items, onChange, placeholder, addLabel }: { items: string[]; onChange: (v: string[]) => void; placeholder: string; addLabel: string }) {
  const refs = useRef<(HTMLTextAreaElement | null)[]>([]);
  const focus = useRef<{ i: number; at: number } | null>(null);
  useLayoutEffect(() => {
    const f = focus.current;
    focus.current = null;
    const el = f && refs.current[f.i];
    if (!el) return;
    el.focus();
    const at = f.at < 0 ? el.value.length : f.at;
    el.setSelectionRange(at, at);
  });
  const set = (next: string[], i?: number, at = -1) => {
    if (i != null) focus.current = { i, at };
    onChange(next);
  };
  const change = (i: number, value: string) => {
    if (!value.includes("\n")) return set(items.map((b, j) => (j === i ? value.slice(0, LIMITS.bullet) : b)));
    const parts = value.split(/\r?\n/).map((s) => s.replace(/^\s*(?:[-*•▪◦]|\d+[.)])\s+/, "").slice(0, LIMITS.bullet)).filter((s, k) => k === 0 || s.trim());
    const next = [...items.slice(0, i), ...parts, ...items.slice(i + 1)].slice(0, LIMITS.bullets);
    set(next, Math.min(i + parts.length - 1, next.length - 1));
  };
  const key = (i: number, e: KeyboardEvent<HTMLTextAreaElement>) => {
    const el = e.currentTarget;
    if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
      e.preventDefault();
      if (items.length >= LIMITS.bullets) return;
      set([...items.slice(0, i), el.value.slice(0, el.selectionStart), el.value.slice(el.selectionEnd), ...items.slice(i + 1)], i + 1, 0);
    } else if (e.key === "Backspace" && i > 0 && el.selectionStart === 0 && el.selectionEnd === 0) {
      e.preventDefault();
      const prev = items[i - 1];
      set([...items.slice(0, i - 1), prev + el.value, ...items.slice(i + 1)], i - 1, prev.length);
    } else if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
      e.preventDefault();
      const d = e.key === "ArrowUp" ? -1 : 1;
      if (i + d >= 0 && i + d < items.length) set(moved(items, i, d), i + d);
    }
  };
  return (
    <div className="bullets-wrap">
      {items.length > 0 && (
        <ul className="bullets">
          {items.map((b, i) => (
            <li className="bullet" key={i}>
              <span className="bullet-dot" aria-hidden="true">•</span>
              <Grow inputRef={(el) => { refs.current[i] = el; }} className="textarea bullet-input" value={b} placeholder={i === 0 ? placeholder : ""}
                onChange={(e) => change(i, e.target.value)} onKeyDown={(e) => key(i, e)} aria-label={`Bullet ${i + 1}`} />
              <span className="row-tools wide-only">
                <IconBtn icon="up" label="Move bullet up" onClick={() => set(moved(items, i, -1), i - 1)} disabled={i === 0} />
                <IconBtn icon="down" label="Move bullet down" onClick={() => set(moved(items, i, 1), i + 1)} disabled={i === items.length - 1} />
                <IconBtn icon="x" label="Remove bullet" onClick={() => set(items.filter((_, j) => j !== i), i > 0 ? i - 1 : undefined)} />
              </span>
              {/* On narrow screens one button holds the same three, so the bullet keeps the width. */}
              <span className="row-tools narrow-only">
                <Menu className="btn btn-quiet btn-icon btn-sm icon-tool" ariaLabel={`Bullet ${i + 1} options`} end label={<Icon name="dots" size={18} />} items={[
                  ...(i > 0 ? [{ label: "Move up", onSelect: () => set(moved(items, i, -1), i - 1) }] : []),
                  ...(i < items.length - 1 ? [{ label: "Move down", onSelect: () => set(moved(items, i, 1), i + 1) }] : []),
                  { label: "Remove bullet", danger: true, onSelect: () => set(items.filter((_, j) => j !== i)) },
                ]} />
              </span>
            </li>
          ))}
        </ul>
      )}
      {items.length < LIMITS.bullets && (
        <button className="btn btn-sm btn-quiet add-bullet" type="button" onClick={() => set([...items, ""], items.length)}><Icon name="plus" size={14} />{addLabel}</button>
      )}
    </div>
  );
}

/** A list of entries (jobs, schools...) that can be added, removed (with Undo), reordered and folded. */
function Entries<T extends { id: string }>({ items, onChange, onRemove, make, addLabel, noun, title, sub, flat, children }: {
  items: T[]; onChange: (items: T[]) => void; onRemove: (i: number) => void; make: () => T; addLabel: string; noun: string;
  title?: (e: T) => string; sub?: (e: T) => string; flat?: boolean;
  children: (e: T, set: (patch: Partial<T>) => void) => ReactNode;
}) {
  const [open, setOpen] = useState<Set<string>>(() => new Set(items.filter((e) => isEmptyEntry(e)).map((e) => e.id)));
  const [leaving, setLeaving] = useState<string | null>(null);
  const fresh = useRef(new Set<string>());
  const toggle = (id: string) => setOpen((s) => {
    const next = new Set(s);
    if (next.has(id)) next.delete(id); else next.add(id);
    return next;
  });
  const add = () => {
    const e = make();
    fresh.current.add(e.id);
    setOpen((s) => new Set(s).add(e.id));
    onChange([...items, e]);
  };
  const tools = (i: number, e: T) => (
    <span className="row-tools">
      <IconBtn icon="up" label={`Move ${noun} up`} onClick={() => onChange(moved(items, i, -1))} disabled={i === 0} />
      <IconBtn icon="down" label={`Move ${noun} down`} onClick={() => onChange(moved(items, i, 1))} disabled={i === items.length - 1} />
      <IconBtn icon="x" label={`Remove ${noun}`} onClick={() => setLeaving(e.id)} />
    </span>
  );
  const done = (e: { animationName: string }, i: number, id: string) => {
    if (leaving === id && e.animationName === "oa-leave") { setLeaving(null); onRemove(i); }
  };
  const cls = (base: string, id: string) => `${base}${fresh.current.has(id) ? " enter" : ""}${leaving === id ? " leaving" : ""}`;
  return (
    <>
      {items.length > 0 && (
        <ol className={flat ? "rows" : "entries"}>
          {items.map((e, i) => {
            const set = (patch: Partial<T>) => onChange(items.map((x, j) => (j === i ? { ...x, ...patch } : x)));
            if (flat) return <li className={cls("row-item", e.id)} key={e.id} onAnimationEnd={(ev) => done(ev, i, e.id)}><div className="row-fields">{children(e, set)}</div>{tools(i, e)}</li>;
            const isOpen = open.has(e.id);
            const s = sub?.(e);
            return (
              <li className={cls("entry", e.id)} key={e.id} onAnimationEnd={(ev) => done(ev, i, e.id)}>
                <div className="entry-head">
                  <button className="entry-toggle" type="button" aria-expanded={isOpen} onClick={() => toggle(e.id)}>
                    <span className="entry-chev" data-open={isOpen}><Icon name="down" size={14} /></span>
                    <span className="entry-name">
                      <strong>{title?.(e) || `New ${noun}`}</strong>
                      {s && <span className="muted">{s}</span>}
                    </span>
                  </button>
                  {tools(i, e)}
                </div>
                {isOpen && <div className="entry-body">{children(e, set)}</div>}
              </li>
            );
          })}
        </ol>
      )}
      {items.length < LIMITS.entries && (
        <button className="btn btn-sm btn-line add-btn" type="button" onClick={add}><Icon name="plus" size={14} />{addLabel}</button>
      )}
    </>
  );
}

const DATE_HINT = "Mar 2021";
const OPTIONAL_NOUN: Record<ListKey, string> = {
  work: "job", education: "school", skills: "skill group", projects: "project", volunteer: "role", awards: "award",
  certificates: "certification", publications: "publication", languages: "language", interests: "interest",
};
const BLANK: Record<ListKey, () => { id: string }> = {
  work: blankWork, education: blankEducation, skills: blankSkill, projects: blankProject, volunteer: blankVolunteer, awards: blankAward,
  certificates: blankCertificate, publications: blankPublication, languages: blankLanguage, interests: blankInterest,
};

export function ResumeForm({ resume: r, onChange, error }: { resume: Resume; onChange: (r: Resume) => void; error?: EditorError | null }) {
  const latest = useRef(r);
  latest.current = r;
  const [renaming, setRenaming] = useState<SectionKey | null>(null);
  const [leaving, setLeaving] = useState<SectionKey | null>(null);
  const [added, setAdded] = useState<SectionKey | null>(null);
  const set = (patch: Partial<Resume>) => onChange({ ...r, ...patch });
  const setBasics = (patch: Partial<Basics>) => set({ basics: { ...r.basics, ...patch } });
  const setSettings = (patch: Partial<Settings>) => set({ settings: { ...r.settings, ...patch } });
  const b = r.basics;
  const order = r.settings.order;
  const links = b.links.length ? b.links : [""];
  const shown = (k: SectionKey) => CORE_SECTIONS.includes(k) || (k !== "summary" && r[k].length > 0);
  const visible = order.filter(shown);
  const hidden = order.filter((k) => !shown(k));

  /** Removes an entry, then offers to put it back where it was. */
  const removeAt = (key: ListKey, i: number) => {
    const list = r[key] as { id: string }[];
    const gone = list[i];
    onChange({ ...r, [key]: list.filter((_, j) => j !== i) });
    if (isEmptyEntry(gone)) return;
    toast(`${OPTIONAL_NOUN[key][0].toUpperCase()}${OPTIONAL_NOUN[key].slice(1)} removed.`, {
      label: "Undo",
      run: () => {
        const now = latest.current;
        const back = [...(now[key] as { id: string }[])];
        back.splice(Math.min(i, back.length), 0, gone);
        onChange({ ...now, [key]: back });
      },
    });
  };
  const removeSection = (key: ListKey) => {
    const kept = r[key];
    onChange({ ...r, [key]: [] });
    if (!(kept as object[]).some((e) => !isEmptyEntry(e))) return;
    toast(`${sectionTitle(r, key)} removed.`, { label: "Undo", run: () => onChange({ ...latest.current, [key]: kept }) });
  };
  const addSection = (key: ListKey) => {
    setAdded(key);
    onChange({ ...r, [key]: [BLANK[key]()] });
    requestAnimationFrame(() => {
      const el = document.getElementById(`section-${key}`);
      el?.scrollIntoView({ block: "nearest" });
      el?.querySelector<HTMLElement>(".entry-body input, .row-fields input")?.focus({ preventScroll: true });
    });
  };
  const list = <K extends ListKey>(key: K) => ({
    items: r[key],
    onChange: (items: Resume[K]) => set({ [key]: items } as Partial<Resume>),
    onRemove: (i: number) => removeAt(key, i),
    make: BLANK[key] as () => Resume[K][number],
    noun: OPTIONAL_NOUN[key],
  });

  const body: Record<SectionKey, () => ReactNode> = {
    summary: () => (
      <Grow className="textarea summary-input" value={b.summary} onChange={(e) => setBasics({ summary: e.target.value })} maxLength={LIMITS.text} aria-label="Summary"
        placeholder="Two or three sentences: what you do, what you are good at, what you want next." />
    ),
    work: () => (
      <Entries {...list("work")} addLabel="Add job" title={(w) => [w.position, w.name].filter(Boolean).join(", ")} sub={(w) => dateRange(w.startDate, w.endDate)}>
        {(w, setW) => (
          <div className="stack fields">
            <div className="two">
              <Field label="Job title" value={w.position} onChange={(position) => setW({ position })} placeholder="Product designer" />
              <Field label="Company" value={w.name} onChange={(name) => setW({ name })} placeholder="Acme Inc." />
            </div>
            <div className="three">
              <Field label="Location" value={w.location} onChange={(location) => setW({ location })} placeholder="Remote" />
              <Field label="Start" value={w.startDate} onChange={(startDate) => setW({ startDate })} placeholder={DATE_HINT} max={40} />
              <Field label="End" value={w.endDate} onChange={(endDate) => setW({ endDate })} placeholder="Present" max={40} />
            </div>
            <label className="field"><span>About the role <em>optional</em></span>
              <Grow className="textarea" value={w.summary} onChange={(e) => setW({ summary: e.target.value })} maxLength={LIMITS.text} placeholder="One line about the team or the product." />
            </label>
            <div className="field"><span>What you did</span>
              <Bullets items={w.highlights} onChange={(highlights) => setW({ highlights })} addLabel="Add bullet"
                placeholder="Led the redesign of checkout, which lifted conversion by 12%" />
            </div>
          </div>
        )}
      </Entries>
    ),
    education: () => (
      <Entries {...list("education")} addLabel="Add school" title={(e) => e.institution || degreeOf(e)} sub={(e) => dateRange(e.startDate, e.endDate)}>
        {(e, setE) => (
          <div className="stack fields">
            <Field label="School" value={e.institution} onChange={(institution) => setE({ institution })} placeholder="University of Lisbon" />
            <div className="two">
              <Field label="Degree" value={e.studyType} onChange={(studyType) => setE({ studyType })} placeholder="BSc" />
              <Field label="Field of study" value={e.area} onChange={(area) => setE({ area })} placeholder="Computer Science" />
            </div>
            <div className="three">
              <Field label="Location" value={e.location} onChange={(location) => setE({ location })} placeholder="Lisbon" />
              <Field label="Start" value={e.startDate} onChange={(startDate) => setE({ startDate })} placeholder="2016" max={40} />
              <Field label="End" value={e.endDate} onChange={(endDate) => setE({ endDate })} placeholder="2020" max={40} />
            </div>
            <Field label="Grade" value={e.score} onChange={(score) => setE({ score })} placeholder="GPA 3.8, First class honours" max={60} />
            <div className="field"><span>Details <em>optional</em></span>
              <Bullets items={e.courses} onChange={(courses) => setE({ courses })} addLabel="Add detail" placeholder="Honors, thesis or relevant courses" />
            </div>
          </div>
        )}
      </Entries>
    ),
    skills: () => (
      <Entries {...list("skills")} addLabel="Add skills" flat>
        {(s, setS) => (
          <div className="pair-row">
            <input className="input" value={s.name} onChange={(e) => setS({ name: e.target.value })} placeholder="Group (optional)" aria-label="Skill group" maxLength={LIMITS.short} />
            <input className="input" value={s.keywords} onChange={(e) => setS({ keywords: e.target.value })} placeholder="Figma, prototyping, user research" aria-label="Skills, separated by commas" maxLength={LIMITS.bullet} />
          </div>
        )}
      </Entries>
    ),
    projects: () => (
      <Entries {...list("projects")} addLabel="Add project" title={(p) => p.name} sub={(p) => dateRange(p.startDate, p.endDate)}>
        {(p, setP) => (
          <div className="stack fields">
            <div className="two">
              <Field label="Project" value={p.name} onChange={(name) => setP({ name })} placeholder="Open source design kit" />
              <Field label="Link" value={p.url} onChange={(url) => setP({ url })} placeholder="github.com/you/project" max={300} />
            </div>
            <div className="two dates">
              <Field label="Start" value={p.startDate} onChange={(startDate) => setP({ startDate })} placeholder={DATE_HINT} max={40} />
              <Field label="End" value={p.endDate} onChange={(endDate) => setP({ endDate })} placeholder="Present" max={40} />
            </div>
            <label className="field"><span>Description <em>optional</em></span>
              <Grow className="textarea" value={p.description} onChange={(e) => setP({ description: e.target.value })} maxLength={LIMITS.text} placeholder="What it is and who uses it." />
            </label>
            <div className="field"><span>Highlights</span>
              <Bullets items={p.highlights} onChange={(highlights) => setP({ highlights })} addLabel="Add bullet" placeholder="Used by 40 teams; 2,000 stars on GitHub" />
            </div>
          </div>
        )}
      </Entries>
    ),
    volunteer: () => (
      <Entries {...list("volunteer")} addLabel="Add role" title={(v) => [v.position, v.organization].filter(Boolean).join(", ")} sub={(v) => dateRange(v.startDate, v.endDate)}>
        {(v, setV) => (
          <div className="stack fields">
            <div className="two">
              <Field label="Role" value={v.position} onChange={(position) => setV({ position })} placeholder="Mentor" />
              <Field label="Organization" value={v.organization} onChange={(organization) => setV({ organization })} placeholder="Code for Lisbon" />
            </div>
            <div className="two dates">
              <Field label="Start" value={v.startDate} onChange={(startDate) => setV({ startDate })} placeholder={DATE_HINT} max={40} />
              <Field label="End" value={v.endDate} onChange={(endDate) => setV({ endDate })} placeholder="Present" max={40} />
            </div>
            <label className="field"><span>About it <em>optional</em></span>
              <Grow className="textarea" value={v.summary} onChange={(e) => setV({ summary: e.target.value })} maxLength={LIMITS.text} placeholder="What the organization does." />
            </label>
            <div className="field"><span>What you did</span>
              <Bullets items={v.highlights} onChange={(highlights) => setV({ highlights })} addLabel="Add bullet" placeholder="Mentored 12 new developers through their first project" />
            </div>
          </div>
        )}
      </Entries>
    ),
    awards: () => (
      <Entries {...list("awards")} addLabel="Add award" title={(a) => a.title} sub={(a) => [a.awarder, fmtDate(a.date)].filter(Boolean).join(", ")}>
        {(a, setA) => (
          <div className="stack fields">
            <Field label="Award" value={a.title} onChange={(title) => setA({ title })} placeholder="Employee of the year" />
            <div className="two">
              <Field label="Given by" value={a.awarder} onChange={(awarder) => setA({ awarder })} placeholder="Acme Inc." />
              <Field label="Date" value={a.date} onChange={(date) => setA({ date })} placeholder="2023" max={40} />
            </div>
            <label className="field"><span>What it was for <em>optional</em></span>
              <Grow className="textarea" value={a.summary} onChange={(e) => setA({ summary: e.target.value })} maxLength={LIMITS.text} placeholder="One line about why you got it." />
            </label>
          </div>
        )}
      </Entries>
    ),
    certificates: () => (
      <Entries {...list("certificates")} addLabel="Add certification" flat>
        {(c, setC) => (
          <div className="cert-row">
            <input className="input cert-name" value={c.name} onChange={(e) => setC({ name: e.target.value })} placeholder="AWS Certified Developer" aria-label="Certification" maxLength={LIMITS.short} />
            <input className="input" value={c.issuer} onChange={(e) => setC({ issuer: e.target.value })} placeholder="Issued by" aria-label="Issued by" maxLength={LIMITS.short} />
            <input className="input" value={c.date} onChange={(e) => setC({ date: e.target.value })} placeholder="2024" aria-label="Date" maxLength={40} />
            <input className="input cert-link" value={c.url} onChange={(e) => setC({ url: e.target.value })} placeholder="Link (optional)" aria-label="Link" maxLength={300} />
          </div>
        )}
      </Entries>
    ),
    publications: () => (
      <Entries {...list("publications")} addLabel="Add publication" title={(p) => p.name} sub={(p) => [p.publisher, fmtDate(p.releaseDate)].filter(Boolean).join(", ")}>
        {(p, setP) => (
          <div className="stack fields">
            <Field label="Title" value={p.name} onChange={(name) => setP({ name })} placeholder="Designing for trust" />
            <div className="three">
              <Field label="Published in" value={p.publisher} onChange={(publisher) => setP({ publisher })} placeholder="UX Journal" />
              <Field label="Date" value={p.releaseDate} onChange={(releaseDate) => setP({ releaseDate })} placeholder="2022" max={40} />
              <Field label="Link" value={p.url} onChange={(url) => setP({ url })} placeholder="doi.org/..." max={300} />
            </div>
            <label className="field"><span>Summary <em>optional</em></span>
              <Grow className="textarea" value={p.summary} onChange={(e) => setP({ summary: e.target.value })} maxLength={LIMITS.text} placeholder="One line about it." />
            </label>
          </div>
        )}
      </Entries>
    ),
    languages: () => (
      <Entries {...list("languages")} addLabel="Add language" flat>
        {(l, setL) => (
          <div className="pair-row pair-even">
            <input className="input" value={l.language} onChange={(e) => setL({ language: e.target.value })} placeholder="Spanish" aria-label="Language" maxLength={LIMITS.short} />
            <input className="input" value={l.fluency} onChange={(e) => setL({ fluency: e.target.value })} placeholder="Native, fluent, B2..." aria-label="Level" maxLength={LIMITS.short} />
          </div>
        )}
      </Entries>
    ),
    interests: () => (
      <Entries {...list("interests")} addLabel="Add interest" flat>
        {(s, setS) => (
          <div className="pair-row">
            <input className="input" value={s.name} onChange={(e) => setS({ name: e.target.value })} placeholder="Group (optional)" aria-label="Interest group" maxLength={LIMITS.short} />
            <input className="input" value={s.keywords} onChange={(e) => setS({ keywords: e.target.value })} placeholder="Climbing, film photography" aria-label="Interests, separated by commas" maxLength={LIMITS.bullet} />
          </div>
        )}
      </Entries>
    ),
  };

  return (
    <div className="form">
      <section className="form-section">
        <h2 className="form-title">Contact</h2>
        <div className="stack fields">
          <div className="two">
            <Field id="field-name" label="Full name" value={b.name} onChange={(name) => setBasics({ name })} placeholder="Sam Rivera" max={120} autoComplete="name"
              error={error?.field === "name" && !b.name.trim() ? error.text : undefined} />
            <Field label="Job title" value={b.label} onChange={(label) => setBasics({ label })} placeholder="Product designer" autoComplete="organization-title" />
          </div>
          <div className="two">
            <Field label="Email" type="email" value={b.email} onChange={(email) => setBasics({ email })} placeholder="sam@example.com" autoComplete="email" />
            <Field label="Phone" type="tel" value={b.phone} onChange={(phone) => setBasics({ phone })} placeholder="+1 555 010 0199" max={60} autoComplete="tel" />
          </div>
          <Field label="Location" value={b.location} onChange={(location) => setBasics({ location })} placeholder="Portland, OR" autoComplete="address-level2" />
          <div className="field">
            <span>Links</span>
            <ul className="links">
              {links.map((l, i) => (
                <li key={i}>
                  <input className="input" value={l} onChange={(e) => setBasics({ links: links.map((x, j) => (j === i ? e.target.value : x)) })} maxLength={300}
                    placeholder={i === 0 ? "linkedin.com/in/samrivera" : "Portfolio, GitHub..."} aria-label={`Link ${i + 1}`} autoComplete="url" />
                  {(links.length > 1 || l) && <IconBtn icon="x" label="Remove link" onClick={() => setBasics({ links: links.filter((_, j) => j !== i) })} />}
                </li>
              ))}
            </ul>
            {links.length < LIMITS.links && links[links.length - 1].trim() !== "" && (
              <button className="btn btn-sm btn-quiet add-bullet" type="button" onClick={() => setBasics({ links: [...links, ""] })}><Icon name="plus" size={14} />Add link</button>
            )}
          </div>
        </div>
      </section>

      {visible.map((key, i) => {
        const title = sectionTitle(r, key);
        // Moving skips sections that are not on the form, so one press always moves it past a visible neighbour.
        const move = (d: number) => {
          const target = visible[i + d];
          if (!target) return;
          const next = order.filter((k) => k !== key);
          next.splice(next.indexOf(target) + (d > 0 ? 1 : 0), 0, key);
          setSettings({ order: next });
        };
        const options: MenuItem[] = [];
        if (i > 0) options.push({ label: "Move up", onSelect: () => move(-1) });
        if (i < visible.length - 1) options.push({ label: "Move down", onSelect: () => move(1) });
        options.push({ label: "Rename heading", onSelect: () => setRenaming(key) });
        if (!CORE_SECTIONS.includes(key)) options.push("-", { label: "Remove section", danger: true, onSelect: () => setLeaving(key) });
        return (
          <section className={`form-section${added === key ? " enter" : ""}${leaving === key ? " leaving" : ""}`} key={key} id={`section-${key}`}
            onAnimationEnd={(e) => {
              if (e.target !== e.currentTarget) return;
              if (leaving === key && e.animationName === "oa-leave") { setLeaving(null); removeSection(key as ListKey); }
              if (added === key) setAdded(null);
            }}>
            <div className="form-head">
              {renaming === key ? (
                <input className="input heading-input" autoFocus defaultValue={r.settings.titles[key] ?? ""} placeholder={DEFAULT_TITLES[key]} maxLength={LIMITS.title} aria-label={`Heading for ${DEFAULT_TITLES[key]}`}
                  onBlur={(e) => { setSettings({ titles: { ...r.settings.titles, [key]: e.target.value.trim() } }); setRenaming(null); }}
                  onKeyDown={(e) => {
                    if (e.key === "Enter") e.currentTarget.blur();
                    if (e.key === "Escape") { e.currentTarget.value = r.settings.titles[key] ?? ""; e.currentTarget.blur(); }
                  }} />
              ) : (
                <h2 className="form-title">{title}</h2>
              )}
              <Menu className="btn btn-quiet btn-icon btn-sm icon-tool" ariaLabel={`${title} options`} end label={<Icon name="dots" size={18} />} items={options} />
            </div>
            {body[key]()}
          </section>
        );
      })}

      {hidden.length > 0 && (
        <section className="form-section add-sections">
          <h2 className="form-title">Add a section</h2>
          <div className="row add-row">
            {hidden.map((k) => (
              <button key={k} className="btn btn-sm btn-line" type="button" onClick={() => addSection(k as ListKey)}>
                <Icon name="plus" size={14} />{DEFAULT_TITLES[k]}
              </button>
            ))}
          </div>
        </section>
      )}
    </div>
  );
}

function LetterForm({ letter, onChange }: { letter: Letter; onChange: (l: Letter) => void }) {
  return (
    <div className="form">
      <section className="form-section">
        <h2 className="form-title">Cover letter</h2>
        <div className="stack fields">
          <label className="field"><span>To <em>optional</em></span>
            <Grow className="textarea" value={letter.to} onChange={(e) => onChange({ ...letter, to: e.target.value })} maxLength={600}
              placeholder={"Alex Lee, Hiring manager\nAcme Inc.\nPortland, OR"} />
          </label>
          <label className="field"><span>Letter</span>
            <Grow className="textarea letter-input" value={letter.body} onChange={(e) => onChange({ ...letter, body: e.target.value })} maxLength={LIMITS.text * 2}
              placeholder={"Dear Ms. Lee,\n\nWhy you want this job, in two or three sentences.\n\nOne or two results from your experience that matter for this role.\n\nKind regards,\nSam Rivera"} />
          </label>
          <p className="hint">Your name and contact details print at the top, from your resume, with today's date. A blank line starts a new paragraph.</p>
        </div>
      </section>
    </div>
  );
}

function DesignBar({ resume, onChange, pages }: { resume: Resume; onChange: (r: Resume) => void; pages: number }) {
  const s = resume.settings;
  const setS = (patch: Partial<Settings>) => onChange({ ...resume, settings: { ...s, ...patch } });
  const color = colorOf(s);
  return (
    <div className="design-bar">
      <div className="segmented segmented-3" role="radiogroup" aria-label="Template">
        {TEMPLATES.map((t) => (
          <button key={t} type="button" role="radio" aria-checked={s.template === t} onClick={() => setS({ template: t })}>{TEMPLATE_NAMES[t]}</button>
        ))}
      </div>
      <div className="swatches" role="radiogroup" aria-label="Color">
        {COLOR_IDS.map((c: ColorId) => (
          <button key={c} type="button" role="radio" aria-checked={color === c} aria-label={c[0].toUpperCase() + c.slice(1)} title={c[0].toUpperCase() + c.slice(1)}
            className="swatch" style={{ "--c": COLORS[c] } as CSSProperties} onClick={() => setS({ color: c })} />
        ))}
      </div>
      <div className="design-side">
        <select className="select select-sm" aria-label="Paper size" value={s.paper} onChange={(e) => setS({ paper: e.target.value as Settings["paper"] })}>
          {PAPERS.map((p) => <option key={p} value={p}>{p === "Letter" ? "US Letter" : "A4"}</option>)}
        </select>
        <span className="pages wide-only" aria-live="polite">{pageCount(pages)}</span>
      </div>
    </div>
  );
}

const pageCount = (n: number) => (n === 1 ? "1 page" : `${n} pages`);

/** Below this width the editor shows the form or the paper, not both; the paper can show reflowed to read. */
const PHONE = "(max-width: 900px)";
export function useMedia(query: string): boolean {
  const [on, setOn] = useState(() => matchMedia(query).matches);
  useEffect(() => {
    const m = matchMedia(query);
    const change = () => setOn(m.matches);
    m.addEventListener("change", change);
    return () => m.removeEventListener("change", change);
  }, [query]);
  return on;
}

/** The paper, with a Pages / Readable switch on phones: readable lays the same resume out at phone width. */
export function PaperView({ resume, letter, placeholders, label }: { resume: Resume; letter?: Letter; placeholders: boolean; label: string }) {
  const narrow = useMedia("(max-width: 640px)");
  const [view, setView] = useState<"read" | "pages">("read");
  const reflow = narrow && view === "read";
  const make = (extra: { reflow?: number }) => (letter ? layoutLetter(resume, letter, { placeholders, ...extra }) : layout(resume, { placeholders, ...extra }));
  const printed = useMemo(() => make({}), [resume, letter, placeholders]);
  const readable = useMemo(() => (reflow ? make({ reflow: REFLOW_WIDTH }) : null), [resume, letter, placeholders, reflow]);
  return (
    <div className="paper-view">
      {narrow && (
        <div className="segmented segmented-2 view-switch" role="radiogroup" aria-label="How to show it">
          <button type="button" role="radio" aria-checked={view === "read"} onClick={() => setView("read")}>Readable</button>
          <button type="button" role="radio" aria-checked={view === "pages"} onClick={() => setView("pages")}>As printed, {pageCount(printed.pages.length)}</button>
        </div>
      )}
      <Paper layout={readable ?? printed} label={label} reflowed={reflow} />
    </div>
  );
}

/** Form and paper side by side, with the actions above the paper; on phones one or the other, with a bar at the bottom
 * that switches between them and holds the actions. */
export function Editor({ resume, onChange, actions, error, top, letter }: {
  resume: Resume; onChange: (r: Resume) => void; actions: (mode: Mode, phone: boolean) => ReactNode; error?: EditorError | null; top?: ReactNode;
  letter?: { value: Letter; onChange: (l: Letter) => void };
}) {
  const [view, setView] = useState<"edit" | "preview">("edit");
  const [mode, setMode] = useState<Mode>("resume");
  const root = useRef<HTMLDivElement>(null);
  const phone = useMedia(PHONE);
  const isLetter = mode === "letter" && letter;
  const lay = useMemo(() => (isLetter ? layoutLetter(resume, letter.value, { placeholders: true }) : layout(resume, { placeholders: true })), [resume, isLetter && letter.value]);
  const pages = lay.pages.length;
  const errorNote = error && !error.field ? <p className="notice notice-error enter" role="alert">{error.text}</p> : null;

  // A missing name sends the person to the field.
  useEffect(() => {
    if (error?.field !== "name") return;
    setMode("resume");
    setView("edit");
    requestAnimationFrame(() => {
      const el = document.getElementById("field-name");
      el?.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
      el?.focus({ preventScroll: true });
    });
  }, [error]);

  // Switching starts at the top of the editor, so the paper (or the form) is in view right away.
  const show = (v: "edit" | "preview") => {
    setView(v);
    root.current?.scrollIntoView();
  };
  return (
    <div className="editor" data-view={view} ref={root}>
      {(top || letter) && (
        <div className="editor-top">
          {letter && (
            <div className="doc-tabs" role="tablist" aria-label="Document" onKeyDown={(e) => {
              if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
              const next = mode === "resume" ? "letter" : "resume";
              setMode(next);
              e.currentTarget.querySelector<HTMLElement>(`[data-mode="${next}"]`)?.focus();
            }}>
              <button type="button" role="tab" data-mode="resume" aria-selected={mode === "resume"} tabIndex={mode === "resume" ? 0 : -1} onClick={() => setMode("resume")}>Resume</button>
              <button type="button" role="tab" data-mode="letter" aria-selected={mode === "letter"} tabIndex={mode === "letter" ? 0 : -1} onClick={() => setMode("letter")}>Cover letter</button>
            </div>
          )}
          {top}
        </div>
      )}
      <div className="editor-grid">
        <div className="editor-form card">
          {isLetter ? <LetterForm letter={letter.value} onChange={letter.onChange} /> : <ResumeForm resume={resume} onChange={onChange} error={error} />}
        </div>
        <div className="editor-preview">
          <div className="preview-sticky">
            {!phone && (
              <div className="editor-actions">
                {errorNote}
                <div className="row">{actions(mode, false)}</div>
              </div>
            )}
            <DesignBar resume={resume} onChange={onChange} pages={pages} />
            {lay.missing.length > 0 && (
              <p className="notice small">These characters are not in the resume fonts, so they print without accents or as "?": {lay.missing.slice(0, 16).join(" ")}</p>
            )}
            <div className="preview-scroll">
              {phone
                ? <PaperView resume={resume} letter={isLetter ? letter.value : undefined} placeholders label={isLetter ? "Cover letter preview" : "Resume preview"} />
                : <Paper layout={lay} label={isLetter ? "Cover letter preview" : "Resume preview"} />}
            </div>
          </div>
        </div>
      </div>
      {phone && (
        <div className="editor-bar">
          {errorNote}
          <div className="editor-bar-row">
            <button className="btn btn-line bar-toggle" type="button" onClick={() => show(view === "edit" ? "preview" : "edit")}>
              {view === "edit" ? "Preview" : "Edit"}
            </button>
            {actions(mode, true)}
          </div>
        </div>
      )}
    </div>
  );
}

Change history

Quality pass: several resumes, cover letters, phone-readable shared pages
3f16b23757 · 5 hours ago
Bounded source sync
b7eb5b26b5 · 1 day ago
Bounded source sync
adae97536e · 1 day ago
Bounded source sync
3b0cf36b39 · 1 day ago