Full Code of xitanggg/open-resume for AI

main 4f8255a2c763 cached
116 files
274.4 KB
73.6k tokens
119 symbols
1 requests
Download .txt
Showing preview only (303K chars total). Download the full file or copy to clipboard to get everything.
Repository: xitanggg/open-resume
Branch: main
Commit: 4f8255a2c763
Files: 116
Total size: 274.4 KB

Directory structure:
gitextract_d24q13tt/

├── .dockerignore
├── .eslintrc.json
├── .gitignore
├── .prettierignore
├── Dockerfile
├── LICENSE
├── README.md
├── jest.config.mjs
├── next.config.js
├── package.json
├── postcss.config.js
├── prettier.config.js
├── prettierignore
├── public/
│   └── fonts/
│       ├── OFL.txt
│       ├── fonts-zh.css
│       └── fonts.css
├── src/
│   └── app/
│       ├── components/
│       │   ├── Button.tsx
│       │   ├── ExpanderWithHeightTransition.tsx
│       │   ├── FlexboxSpacer.tsx
│       │   ├── Resume/
│       │   │   ├── ResumeControlBar.tsx
│       │   │   ├── ResumeIFrame.tsx
│       │   │   ├── ResumePDF/
│       │   │   │   ├── ResumePDFCustom.tsx
│       │   │   │   ├── ResumePDFEducation.tsx
│       │   │   │   ├── ResumePDFProfile.tsx
│       │   │   │   ├── ResumePDFProject.tsx
│       │   │   │   ├── ResumePDFSkills.tsx
│       │   │   │   ├── ResumePDFWorkExperience.tsx
│       │   │   │   ├── common/
│       │   │   │   │   ├── ResumePDFIcon.tsx
│       │   │   │   │   ├── SuppressResumePDFErrorMessage.tsx
│       │   │   │   │   └── index.tsx
│       │   │   │   ├── index.tsx
│       │   │   │   └── styles.ts
│       │   │   ├── hooks.tsx
│       │   │   └── index.tsx
│       │   ├── ResumeDropzone.tsx
│       │   ├── ResumeForm/
│       │   │   ├── CustomForm.tsx
│       │   │   ├── EducationsForm.tsx
│       │   │   ├── Form/
│       │   │   │   ├── FeaturedSkillInput.tsx
│       │   │   │   ├── IconButton.tsx
│       │   │   │   ├── InputGroup.tsx
│       │   │   │   └── index.tsx
│       │   │   ├── ProfileForm.tsx
│       │   │   ├── ProjectsForm.tsx
│       │   │   ├── SkillsForm.tsx
│       │   │   ├── ThemeForm/
│       │   │   │   ├── InlineInput.tsx
│       │   │   │   ├── Selection.tsx
│       │   │   │   ├── constants.ts
│       │   │   │   └── index.tsx
│       │   │   ├── WorkExperiencesForm.tsx
│       │   │   ├── index.tsx
│       │   │   └── types.ts
│       │   ├── Tooltip.tsx
│       │   ├── TopNavBar.tsx
│       │   ├── documentation/
│       │   │   ├── Badge.tsx
│       │   │   ├── Heading.tsx
│       │   │   ├── Link.tsx
│       │   │   ├── Paragraph.tsx
│       │   │   ├── Table.tsx
│       │   │   └── index.tsx
│       │   └── fonts/
│       │       ├── FontsZh.tsx
│       │       ├── NonEnglishFontsCSSLoader.tsx
│       │       ├── constants.ts
│       │       ├── hooks.tsx
│       │       └── lib.ts
│       ├── globals-css.ts
│       ├── globals.css
│       ├── home/
│       │   ├── AutoTypingResume.tsx
│       │   ├── Features.tsx
│       │   ├── Hero.tsx
│       │   ├── LogoCloud.tsx
│       │   ├── QuestionsAndAnswers.tsx
│       │   ├── Steps.tsx
│       │   ├── Testimonials.tsx
│       │   └── constants.ts
│       ├── layout.tsx
│       ├── lib/
│       │   ├── __tests__/
│       │   │   ├── cx.test.ts
│       │   │   └── make-object-char-iterator.test.ts
│       │   ├── constants.ts
│       │   ├── cx.ts
│       │   ├── deep-clone.ts
│       │   ├── deep-merge.ts
│       │   ├── get-px-per-rem.ts
│       │   ├── hooks/
│       │   │   ├── useAutosizeTextareaHeight.tsx
│       │   │   └── useTailwindBreakpoints.tsx
│       │   ├── make-object-char-iterator.ts
│       │   ├── parse-resume-from-pdf/
│       │   │   ├── extract-resume-from-sections/
│       │   │   │   ├── extract-education.ts
│       │   │   │   ├── extract-profile.ts
│       │   │   │   ├── extract-project.ts
│       │   │   │   ├── extract-resume-from-sections.test.ts
│       │   │   │   ├── extract-skills.ts
│       │   │   │   ├── extract-work-experience.ts
│       │   │   │   ├── index.ts
│       │   │   │   └── lib/
│       │   │   │       ├── bullet-points.ts
│       │   │   │       ├── common-features.ts
│       │   │   │       ├── feature-scoring-system.ts
│       │   │   │       ├── get-section-lines.ts
│       │   │   │       └── subsections.ts
│       │   │   ├── group-lines-into-sections.ts
│       │   │   ├── group-text-items-into-lines.ts
│       │   │   ├── index.ts
│       │   │   ├── read-pdf.ts
│       │   │   └── types.ts
│       │   └── redux/
│       │       ├── hooks.tsx
│       │       ├── local-storage.ts
│       │       ├── resumeSlice.ts
│       │       ├── settingsSlice.ts
│       │       ├── store.ts
│       │       └── types.ts
│       ├── page.tsx
│       ├── resume-builder/
│       │   └── page.tsx
│       ├── resume-import/
│       │   └── page.tsx
│       └── resume-parser/
│           ├── ResumeParserAlgorithmArticle.tsx
│           ├── ResumeTable.tsx
│           └── page.tsx
├── tailwind.config.js
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
node_modules
.next
.vscode
.git

================================================
FILE: .eslintrc.json
================================================
{
  "extends": "next/core-web-vitals",
  "rules": {
    "react/no-unescaped-entities": "off"
  }
}


================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts


================================================
FILE: .prettierignore
================================================
.next
public/fonts/fonts.css

================================================
FILE: Dockerfile
================================================
FROM node:18-alpine as builder
WORKDIR /app
COPY . .
RUN npm install --include=dev
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone .
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000
CMD ["node", "server.js"]

================================================
FILE: LICENSE
================================================
                    GNU AFFERO GENERAL PUBLIC LICENSE
                       Version 3, 19 November 2007

 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.

                            Preamble

  The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.

  The licenses for most software and other practical works are designed
to take away your freedom to share and change the works.  By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.

  When we speak of free software, we are referring to freedom, not
price.  Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.

  Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.

  A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate.  Many developers of free software are heartened and
encouraged by the resulting cooperation.  However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.

  The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community.  It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server.  Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.

  An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals.  This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.

  The precise terms and conditions for copying, distribution and
modification follow.

                       TERMS AND CONDITIONS

  0. Definitions.

  "This License" refers to version 3 of the GNU Affero General Public License.

  "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.

  "The Program" refers to any copyrightable work licensed under this
License.  Each licensee is addressed as "you".  "Licensees" and
"recipients" may be individuals or organizations.

  To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy.  The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.

  A "covered work" means either the unmodified Program or a work based
on the Program.

  To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy.  Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.

  To "convey" a work means any kind of propagation that enables other
parties to make or receive copies.  Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.

  An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License.  If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.

  1. Source Code.

  The "source code" for a work means the preferred form of the work
for making modifications to it.  "Object code" means any non-source
form of a work.

  A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.

  The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form.  A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.

  The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities.  However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work.  For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.

  The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.

  The Corresponding Source for a work in source code form is that
same work.

  2. Basic Permissions.

  All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met.  This License explicitly affirms your unlimited
permission to run the unmodified Program.  The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work.  This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.

  You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force.  You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright.  Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.

  Conveying under any other circumstances is permitted solely under
the conditions stated below.  Sublicensing is not allowed; section 10
makes it unnecessary.

  3. Protecting Users' Legal Rights From Anti-Circumvention Law.

  No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.

  When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.

  4. Conveying Verbatim Copies.

  You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.

  You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.

  5. Conveying Modified Source Versions.

  You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:

    a) The work must carry prominent notices stating that you modified
    it, and giving a relevant date.

    b) The work must carry prominent notices stating that it is
    released under this License and any conditions added under section
    7.  This requirement modifies the requirement in section 4 to
    "keep intact all notices".

    c) You must license the entire work, as a whole, under this
    License to anyone who comes into possession of a copy.  This
    License will therefore apply, along with any applicable section 7
    additional terms, to the whole of the work, and all its parts,
    regardless of how they are packaged.  This License gives no
    permission to license the work in any other way, but it does not
    invalidate such permission if you have separately received it.

    d) If the work has interactive user interfaces, each must display
    Appropriate Legal Notices; however, if the Program has interactive
    interfaces that do not display Appropriate Legal Notices, your
    work need not make them do so.

  A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit.  Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.

  6. Conveying Non-Source Forms.

  You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:

    a) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by the
    Corresponding Source fixed on a durable physical medium
    customarily used for software interchange.

    b) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by a
    written offer, valid for at least three years and valid for as
    long as you offer spare parts or customer support for that product
    model, to give anyone who possesses the object code either (1) a
    copy of the Corresponding Source for all the software in the
    product that is covered by this License, on a durable physical
    medium customarily used for software interchange, for a price no
    more than your reasonable cost of physically performing this
    conveying of source, or (2) access to copy the
    Corresponding Source from a network server at no charge.

    c) Convey individual copies of the object code with a copy of the
    written offer to provide the Corresponding Source.  This
    alternative is allowed only occasionally and noncommercially, and
    only if you received the object code with such an offer, in accord
    with subsection 6b.

    d) Convey the object code by offering access from a designated
    place (gratis or for a charge), and offer equivalent access to the
    Corresponding Source in the same way through the same place at no
    further charge.  You need not require recipients to copy the
    Corresponding Source along with the object code.  If the place to
    copy the object code is a network server, the Corresponding Source
    may be on a different server (operated by you or a third party)
    that supports equivalent copying facilities, provided you maintain
    clear directions next to the object code saying where to find the
    Corresponding Source.  Regardless of what server hosts the
    Corresponding Source, you remain obligated to ensure that it is
    available for as long as needed to satisfy these requirements.

    e) Convey the object code using peer-to-peer transmission, provided
    you inform other peers where the object code and Corresponding
    Source of the work are being offered to the general public at no
    charge under subsection 6d.

  A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.

  A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling.  In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage.  For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product.  A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.

  "Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source.  The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.

  If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information.  But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).

  The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed.  Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.

  Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.

  7. Additional Terms.

  "Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law.  If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.

  When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it.  (Additional permissions may be written to require their own
removal in certain cases when you modify the work.)  You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.

  Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:

    a) Disclaiming warranty or limiting liability differently from the
    terms of sections 15 and 16 of this License; or

    b) Requiring preservation of specified reasonable legal notices or
    author attributions in that material or in the Appropriate Legal
    Notices displayed by works containing it; or

    c) Prohibiting misrepresentation of the origin of that material, or
    requiring that modified versions of such material be marked in
    reasonable ways as different from the original version; or

    d) Limiting the use for publicity purposes of names of licensors or
    authors of the material; or

    e) Declining to grant rights under trademark law for use of some
    trade names, trademarks, or service marks; or

    f) Requiring indemnification of licensors and authors of that
    material by anyone who conveys the material (or modified versions of
    it) with contractual assumptions of liability to the recipient, for
    any liability that these contractual assumptions directly impose on
    those licensors and authors.

  All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10.  If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term.  If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.

  If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.

  Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.

  8. Termination.

  You may not propagate or modify a covered work except as expressly
provided under this License.  Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).

  However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.

  Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.

  Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License.  If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.

  9. Acceptance Not Required for Having Copies.

  You are not required to accept this License in order to receive or
run a copy of the Program.  Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance.  However,
nothing other than this License grants you permission to propagate or
modify any covered work.  These actions infringe copyright if you do
not accept this License.  Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.

  10. Automatic Licensing of Downstream Recipients.

  Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License.  You are not responsible
for enforcing compliance by third parties with this License.

  An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations.  If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.

  You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License.  For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.

  11. Patents.

  A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based.  The
work thus licensed is called the contributor's "contributor version".

  A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version.  For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.

  Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.

  In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement).  To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.

  If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients.  "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.

  If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.

  A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License.  You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.

  Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.

  12. No Surrender of Others' Freedom.

  If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License.  If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all.  For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.

  13. Remote Network Interaction; Use with the GNU General Public License.

  Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software.  This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.

  Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work.  The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.

  14. Revised Versions of this License.

  The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time.  Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

  Each version is given a distinguishing version number.  If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation.  If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.

  If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.

  Later license versions may give you additional or different
permissions.  However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.

  15. Disclaimer of Warranty.

  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

  16. Limitation of Liability.

  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.

  17. Interpretation of Sections 15 and 16.

  If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.

                     END OF TERMS AND CONDITIONS

            How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.

  To do so, attach the following notices to the program.  It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published
    by the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.

Also add information on how to contact you by electronic and paper mail.

  If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source.  For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code.  There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.

  You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

================================================
FILE: README.md
================================================
# OpenResume

OpenResume is a powerful open-source resume builder and resume parser.

The goal of OpenResume is to provide everyone with free access to a modern professional resume design and enable anyone to apply for jobs with confidence.

Official site: [https://open-resume.com](https://open-resume.com)

## ⚒️ Resume Builder

OpenResume's resume builder allows user to create a modern professional resume easily.

![Resume Builder Demo](https://i.ibb.co/jzcrrt8/resume-builder-demo-optimize.gif)

It has 5 Core Features:
| <div style="width:285px">**Feature**</div> | **Description** |
|---|---|
| **1. Real Time UI Update** | The resume PDF is updated in real time as you enter your resume information, so you can easily see the final output. |
| **2. Modern Professional Resume Design** | The resume PDF is a modern professional design that adheres to U.S. best practices and is ATS friendly to top ATS platforms such as Greenhouse and Lever. It automatically formats fonts, sizes, margins, bullet points to ensure consistency and avoid human errors. |
| **3. Privacy Focus** | The app only runs locally on your browser, meaning no sign up is required and no data ever leaves your browser, so it gives you peace of mind on your personal data. (Fun fact: Running only locally means the app still works even if you disconnect the internet.) |
| **4. Import From Existing Resume PDF** | If you already have an existing resume PDF, you have the option to import it directly, so you can update your resume design to a modern professional design in literally a few seconds. |
| **5. Successful Track Record** | OpenResume users have landed interviews and offers from top companies, such as Dropbox, Google, Meta to name a few. It has been proven to work and liken by recruiters and hiring managers. |

## 🔍 Resume Parser

OpenResume’s second component is the resume parser. For those who have an existing resume, the resume parser can help test and confirm its ATS readability.

![Resume Parser Demo](https://i.ibb.co/JvSVwNk/resume-parser-demo-optimize.gif)

You can learn more about the resume parser algorithm in the ["Resume Parser Algorithm Deep Dive" section](https://open-resume.com/resume-parser).

## 📚 Tech Stack

| <div style="width:140px">**Category**</div> | <div style="width:100px">**Choice**</div> | **Descriptions** |
|---|---|---|
| **Language** | [TypeScript](https://github.com/microsoft/TypeScript) | TypeScript is JavaScript with static type checking and helps catch many silly bugs at code time. |
| **UI Library** | [React](https://github.com/facebook/react) | React’s declarative syntax and component-based architecture make it simple to develop reactive reusable components. |
| **State Management** | [Redux Toolkit](https://github.com/reduxjs/redux-toolkit) | Redux toolkit reduces the boilerplate to set up and update a central redux store, which is used in managing the complex resume state. |
| **CSS Framework** | [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) | Tailwind speeds up development by providing helpful css utilities and removing the need to context switch between tsx and css files. |
| **Web Framework** | [NextJS 13](https://github.com/vercel/next.js) | Next.js supports static site generation and helps build efficient React webpages that support SEO. |
| **PDF Reader** | [PDF.js](https://github.com/mozilla/pdf.js) | PDF.js reads content from PDF files and is used by the resume parser at its first step to read a resume PDF’s content. |
| **PDF Renderer** | [React-pdf](https://github.com/diegomura/react-pdf) | React-pdf creates PDF files and is used by the resume builder to create a downloadable PDF file. |

## 📁 Project Structure

OpenResume is created with the NextJS web framework and follows its project structure. The source code can be found in `src/app`. There are a total of 4 page routes as shown in the table below. (Code path is relative to `src/app`)

| <div style="width:115px">**Page Route**</div> | **Code Path** | **Description** |
|---|---|---|
| / | /page.tsx | Home page that contains hero, auto typing resume, steps, testimonials, logo cloud, etc |
| /resume-import | /resume-import/page.tsx | Resume import page, where you can choose to import data from an existing resume PDF. The main component used is `ResumeDropzone` (`/components/ResumeDropzone.tsx`) |
| /resume-builder | /resume-builder/page.tsx | Resume builder page to build and download a resume PDF. The main components used are `ResumeForm` (`/components/ResumeForm`) and `Resume` (`/components/Resume`) |
| /resume-parser | /resume-parser/page.tsx | Resume parser page to test a resume’s AST readability. The main library util used is `parseResumeFromPdf` (`/lib/parse-resume-from-pdf`) |

## 💻 Local Development

### Method 1: npm

1. Download the repo `git clone https://github.com/xitanggg/open-resume.git`
2. Change the directory `cd open-resume`
3. Install the dependency `npm install`
4. Start a development server `npm run dev`
5. Open your browser and visit [http://localhost:3000](http://localhost:3000) to see OpenResume live

### Method 2: Docker

1. Download the repo `git clone https://github.com/xitanggg/open-resume.git`
2. Change the directory `cd open-resume`
3. Build the container `docker build -t open-resume .`
4. Start the container `docker run -p 3000:3000 open-resume`
5. Open your browser and visit [http://localhost:3000](http://localhost:3000) to see OpenResume live


================================================
FILE: jest.config.mjs
================================================
import nextJest from "next/jest.js";

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
  dir: "./",
});

// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const config = {
  // Add more setup options before each test is run
  // setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],

  testEnvironment: "jest-environment-jsdom",
};

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);


================================================
FILE: next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Nextjs has an issue with pdfjs-dist which optionally uses the canvas package
  // for Node.js compatibility. This causes a "Module parse failed" error when
  // building the app. Since pdfjs-dist is only used on client side, we disable
  // the canvas package for webpack
  // https://github.com/mozilla/pdf.js/issues/16214
  output: 'standalone',
  webpack: (config) => {
    // Setting resolve.alias to false tells webpack to ignore a module
    // https://webpack.js.org/configuration/resolve/#resolvealias
    config.resolve.alias.canvas = false;
    config.resolve.alias.encoding = false;
    return config;
  },
};

module.exports = nextConfig;


================================================
FILE: package.json
================================================
{
  "name": "open-resume",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest --watch",
    "test:ci": "jest --ci"
  },
  "dependencies": {
    "@heroicons/react": "^2.0.18",
    "@react-pdf/renderer": "^3.1.10",
    "@reduxjs/toolkit": "^1.9.5",
    "@types/node": "20.2.5",
    "@types/react": "18.2.7",
    "@types/react-dom": "18.2.4",
    "@vercel/analytics": "^1.0.1",
    "autoprefixer": "10.4.14",
    "eslint": "8.41.0",
    "eslint-config-next": "13.4.4",
    "next": "13.4.4",
    "pdfjs": "^2.5.0",
    "pdfjs-dist": "^3.7.107",
    "postcss": "8.4.24",
    "react": "18.2.0",
    "react-contenteditable": "^3.3.7",
    "react-dom": "18.2.0",
    "react-frame-component": "^5.2.6",
    "react-redux": "^8.0.7",
    "tailwind-scrollbar": "^3.0.4",
    "tailwindcss": "3.3.2",
    "typescript": "5.0.4"
  },
  "devDependencies": {
    "@tailwindcss/aspect-ratio": "^0.4.2",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^14.0.0",
    "jest": "^29.5.0",
    "jest-environment-jsdom": "^29.5.0",
    "prettier-plugin-tailwindcss": "^0.2.1"
  }
}


================================================
FILE: postcss.config.js
================================================
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}


================================================
FILE: prettier.config.js
================================================
module.exports = {
  // Repo: https://github.com/tailwindlabs/prettier-plugin-tailwindcss
  plugins: [require("prettier-plugin-tailwindcss")],
};


================================================
FILE: prettierignore
================================================
public/fonts/*.css

================================================
FILE: public/fonts/OFL.txt
================================================
Copyright (c), Christian Robertson (https://fonts.google.com/specimen/Roboto/about?query=Roboto),
with Reserved Font Name Roboto.

Copyright (c), Łukasz Dziedzic (https://fonts.google.com/specimen/Lato?query=lato),
with Reserved Font Name Lato.

Copyright (c), Julieta Ulanovsky, Sol Matas, Juan Pablo del Peral, Jacques Le Bailly (https://fonts.google.com/specimen/Montserrat?query=Montserrat),
with Reserved Font Name Montserrat.

Copyright (c), Steve Matteson (https://fonts.google.com/specimen/Open+Sans?query=Open+Sans),
with Reserved Font Name Open Sans.

Copyright (c), Matt McInerney, Pablo Impallari, Rodrigo Fuenzalida (https://fonts.google.com/specimen/Raleway?query=Raleway),
with Reserved Font Name Raleway.

Copyright (c), Andrés Torresi, Carolina Giovanolli (https://fonts.google.com/specimen/Caladea?query=Caladea),
with Reserved Font Name Caladea.

Copyright (c), Cyreal (https://fonts.google.com/specimen/Lora?query=Lora),
with Reserved Font Name Lora.

Copyright (c), Christian Robertson (https://fonts.google.com/specimen/Roboto+Slab/about?query=Roboto+Slab),
with Reserved Font Name Roboto Slab.

Copyright (c), Claus Eggers Sørensen (https://fonts.google.com/specimen/Playfair+Display?query=Playfair+Display),
with Reserved Font Name Playfair Display.

Copyright (c), Sorkin Type (https://fonts.google.com/specimen/Merriweather/about?query=Merriweather),
with Reserved Font Name Merriweather.

Copyright (c), (https://fonts.google.com/noto/specimen/Noto+Sans+SC?query=Noto+Sans+SC&noto.query=Noto+Sans+SC),
with Reserved Font Name Noto Sans Simplified Chinese.

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL


-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded, 
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.


================================================
FILE: public/fonts/fonts-zh.css
================================================
@font-face {font-family: "NotoSansSC"; src: url("/fonts/NotoSansSC-Regular.ttf");}
@font-face {font-family: "NotoSansSC"; src: url("/fonts/NotoSansSC-Bold.ttf"); font-weight: bold;}

================================================
FILE: public/fonts/fonts.css
================================================
/* Adding a new English font family needs to keep "public\fonts\fonts.ts" in sync */
/* Sans Serif Fonts */
@font-face {font-family: "Roboto"; src: url("/fonts/Roboto-Regular.ttf");}
@font-face {font-family: "Roboto"; src: url("/fonts/Roboto-Bold.ttf"); font-weight: bold;}
@font-face {font-family: "Lato"; src: url("/fonts/Lato-Regular.ttf");}
@font-face {font-family: "Lato"; src: url("/fonts/Lato-Bold.ttf"); font-weight: bold;}
@font-face {font-family: "Montserrat"; src: url("/fonts/Montserrat-Regular.ttf");}
@font-face {font-family: "Montserrat"; src: url("/fonts/Montserrat-Bold.ttf"); font-weight: bold;}
@font-face {font-family: "OpenSans"; src: url("/fonts/OpenSans-Regular.ttf");}
@font-face {font-family: "OpenSans"; src: url("/fonts/OpenSans-Bold.ttf"); font-weight: bold;}
@font-face {font-family: "Raleway"; src: url("/fonts/Raleway-Regular.ttf");}
@font-face {font-family: "Raleway"; src: url("/fonts/Raleway-Bold.ttf"); font-weight: bold;}

/* Serif Fonts */
@font-face {font-family: "Caladea"; src: url("/fonts/Caladea-Regular.ttf");}
@font-face {font-family: "Caladea"; src: url("/fonts/Caladea-Bold.ttf"); font-weight: bold;}
@font-face {font-family: "Lora"; src: url("/fonts/Lora-Regular.ttf");}
@font-face {font-family: "Lora"; src: url("/fonts/Lora-Bold.ttf"); font-weight: bold;}
@font-face {font-family: "RobotoSlab"; src: url("/fonts/RobotoSlab-Regular.ttf");}
@font-face {font-family: "RobotoSlab"; src: url("/fonts/RobotoSlab-Bold.ttf"); font-weight: bold;}
@font-face {font-family: "PlayfairDisplay"; src: url("/fonts/PlayfairDisplay-Regular.ttf");}
@font-face {font-family: "PlayfairDisplay"; src: url("/fonts/PlayfairDisplay-Bold.ttf"); font-weight: bold;}
@font-face {font-family: "Merriweather"; src: url("/fonts/Merriweather-Regular.ttf");}
@font-face {font-family: "Merriweather"; src: url("/fonts/Merriweather-Bold.ttf"); font-weight: bold;}


================================================
FILE: src/app/components/Button.tsx
================================================
import { cx } from "lib/cx";
import { Tooltip } from "components/Tooltip";

type ReactButtonProps = React.ComponentProps<"button">;
type ReactAnchorProps = React.ComponentProps<"a">;
type ButtonProps = ReactButtonProps | ReactAnchorProps;

const isAnchor = (props: ButtonProps): props is ReactAnchorProps => {
  return "href" in props;
};

export const Button = (props: ButtonProps) => {
  if (isAnchor(props)) {
    return <a {...props} />;
  } else {
    return <button type="button" {...props} />;
  }
};

export const PrimaryButton = ({ className, ...props }: ButtonProps) => (
  <Button className={cx("btn-primary", className)} {...props} />
);

type IconButtonProps = ButtonProps & {
  size?: "small" | "medium";
  tooltipText: string;
};

export const IconButton = ({
  className,
  size = "medium",
  tooltipText,
  ...props
}: IconButtonProps) => (
  <Tooltip text={tooltipText}>
    <Button
      type="button"
      className={cx(
        "rounded-full outline-none hover:bg-gray-100 focus-visible:bg-gray-100",
        size === "medium" ? "p-1.5" : "p-1",
        className
      )}
      {...props}
    />
  </Tooltip>
);


================================================
FILE: src/app/components/ExpanderWithHeightTransition.tsx
================================================
/**
 * ExpanderWithHeightTransition is a div wrapper with built-in transition animation based on height.
 * If expanded is true, it slowly expands its content and vice versa.
 *
 * Note: There is no easy way to animate height transition in CSS: https://github.com/w3c/csswg-drafts/issues/626.
 * This is a clever solution based on css grid and is borrowed from https://css-tricks.com/css-grid-can-do-auto-height-transitions/
 *
 */
export const ExpanderWithHeightTransition = ({
  expanded,
  children,
}: {
  expanded: boolean;
  children: React.ReactNode;
}) => {
  return (
    <div
      className={`grid overflow-hidden transition-all duration-300 ${
        expanded ? "visible" : "invisible"
      }`}
      style={{ gridTemplateRows: expanded ? "1fr" : "0fr" }}
    >
      <div className="min-h-0">{children}</div>
    </div>
  );
};


================================================
FILE: src/app/components/FlexboxSpacer.tsx
================================================
/**
 * FlexboxSpacer can be used to create empty space in flex.
 * It is a div that grows to fill the available space specified by maxWidth.
 * You can also set a minimum width with minWidth.
 */
export const FlexboxSpacer = ({
  maxWidth,
  minWidth = 0,
  className = "",
}: {
  maxWidth: number;
  minWidth?: number;
  className?: string;
}) => (
  <div
    className={`invisible shrink-[10000] grow ${className}`}
    style={{ maxWidth: `${maxWidth}px`, minWidth: `${minWidth}px` }}
  />
);


================================================
FILE: src/app/components/Resume/ResumeControlBar.tsx
================================================
"use client";
import { useEffect } from "react";
import { useSetDefaultScale } from "components/Resume/hooks";
import {
  MagnifyingGlassIcon,
  ArrowDownTrayIcon,
} from "@heroicons/react/24/outline";
import { usePDF } from "@react-pdf/renderer";
import dynamic from "next/dynamic";

const ResumeControlBar = ({
  scale,
  setScale,
  documentSize,
  document,
  fileName,
}: {
  scale: number;
  setScale: (scale: number) => void;
  documentSize: string;
  document: JSX.Element;
  fileName: string;
}) => {
  const { scaleOnResize, setScaleOnResize } = useSetDefaultScale({
    setScale,
    documentSize,
  });

  const [instance, update] = usePDF({ document });

  // Hook to update pdf when document changes
  useEffect(() => {
    update();
  }, [update, document]);

  return (
    <div className="sticky bottom-0 left-0 right-0 flex h-[var(--resume-control-bar-height)] items-center justify-center px-[var(--resume-padding)] text-gray-600 lg:justify-between">
      <div className="flex items-center gap-2">
        <MagnifyingGlassIcon className="h-5 w-5" aria-hidden="true" />
        <input
          type="range"
          min={0.5}
          max={1.5}
          step={0.01}
          value={scale}
          onChange={(e) => {
            setScaleOnResize(false);
            setScale(Number(e.target.value));
          }}
        />
        <div className="w-10">{`${Math.round(scale * 100)}%`}</div>
        <label className="hidden items-center gap-1 lg:flex">
          <input
            type="checkbox"
            className="mt-0.5 h-4 w-4"
            checked={scaleOnResize}
            onChange={() => setScaleOnResize((prev) => !prev)}
          />
          <span className="select-none">Autoscale</span>
        </label>
      </div>
      <a
        className="ml-1 flex items-center gap-1 rounded-md border border-gray-300 px-3 py-0.5 hover:bg-gray-100 lg:ml-8"
        href={instance.url!}
        download={fileName}
      >
        <ArrowDownTrayIcon className="h-4 w-4" />
        <span className="whitespace-nowrap">Download Resume</span>
      </a>
    </div>
  );
};

/**
 * Load ResumeControlBar client side since it uses usePDF, which is a web specific API
 */
export const ResumeControlBarCSR = dynamic(
  () => Promise.resolve(ResumeControlBar),
  {
    ssr: false,
  }
);

export const ResumeControlBarBorder = () => (
  <div className="absolute bottom-[var(--resume-control-bar-height)] w-full border-t-2 bg-gray-50" />
);


================================================
FILE: src/app/components/Resume/ResumeIFrame.tsx
================================================
"use client";
import { useMemo } from "react";
import Frame from "react-frame-component";
import {
  A4_HEIGHT_PX,
  A4_WIDTH_PX,
  A4_WIDTH_PT,
  LETTER_HEIGHT_PX,
  LETTER_WIDTH_PX,
  LETTER_WIDTH_PT,
} from "lib/constants";
import dynamic from "next/dynamic";
import { getAllFontFamiliesToLoad } from "components/fonts/lib";

const getIframeInitialContent = (isA4: boolean) => {
  const width = isA4 ? A4_WIDTH_PT : LETTER_WIDTH_PT;
  const allFontFamilies = getAllFontFamiliesToLoad();

  const allFontFamiliesPreloadLinks = allFontFamilies
    .map(
      (
        font
      ) => `<link rel="preload" as="font" href="/fonts/${font}-Regular.ttf" type="font/ttf" crossorigin="anonymous">
<link rel="preload" as="font" href="/fonts/${font}-Bold.ttf" type="font/ttf" crossorigin="anonymous">`
    )
    .join("");

  const allFontFamiliesFontFaces = allFontFamilies
    .map(
      (
        font
      ) => `@font-face {font-family: "${font}"; src: url("/fonts/${font}-Regular.ttf");}
@font-face {font-family: "${font}"; src: url("/fonts/${font}-Bold.ttf"); font-weight: bold;}`
    )
    .join("");

  return `<!DOCTYPE html>
<html>
  <head>
    ${allFontFamiliesPreloadLinks}
    <style>
      ${allFontFamiliesFontFaces}
    </style>
  </head>
  <body style='overflow: hidden; width: ${width}pt; margin: 0; padding: 0; -webkit-text-size-adjust:none;'>
    <div></div>
  </body>
</html>`;
};

/**
 * Iframe is used here for style isolation, since react pdf uses pt unit.
 * It creates a sandbox document body that uses letter/A4 pt size as width.
 */
const ResumeIframe = ({
  documentSize,
  scale,
  children,
  enablePDFViewer = false,
}: {
  documentSize: string;
  scale: number;
  children: React.ReactNode;
  enablePDFViewer?: boolean;
}) => {
  const isA4 = documentSize === "A4";
  const iframeInitialContent = useMemo(
    () => getIframeInitialContent(isA4),
    [isA4]
  );

  if (enablePDFViewer) {
    return (
      <DynamicPDFViewer className="h-full w-full">
        {children as any}
      </DynamicPDFViewer>
    );
  }
  const width = isA4 ? A4_WIDTH_PX : LETTER_WIDTH_PX;
  const height = isA4 ? A4_HEIGHT_PX : LETTER_HEIGHT_PX;

  return (
    <div
      style={{
        maxWidth: `${width * scale}px`,
        maxHeight: `${height * scale}px`,
      }}
    >
      {/* There is an outer div and an inner div here. The inner div sets the iframe width and uses transform scale to zoom in/out the resume iframe.
        While zooming out or scaling down via transform, the element appears smaller but still occupies the same width/height. Therefore, we use the 
        outer div to restrict the max width & height proportionally */}
      <div
        style={{
          width: `${width}px`,
          height: `${height}px`,
          transform: `scale(${scale})`,
        }}
        className={`origin-top-left bg-white shadow-lg`}
      >
        <Frame
          style={{ width: "100%", height: "100%" }}
          initialContent={iframeInitialContent}
          // key is used to force component to re-mount when document size changes
          key={isA4 ? "A4" : "LETTER"}
        >
          {children}
        </Frame>
      </div>
    </div>
  );
};

/**
 * Load iframe client side since iframe can't be SSR
 */
export const ResumeIframeCSR = dynamic(() => Promise.resolve(ResumeIframe), {
  ssr: false,
});

// PDFViewer is only used for debugging. Its size is quite large, so we make it dynamic import
const DynamicPDFViewer = dynamic(
  () => import("@react-pdf/renderer").then((module) => module.PDFViewer),
  {
    ssr: false,
  }
);


================================================
FILE: src/app/components/Resume/ResumePDF/ResumePDFCustom.tsx
================================================
import { View } from "@react-pdf/renderer";
import {
  ResumePDFSection,
  ResumePDFBulletList,
} from "components/Resume/ResumePDF/common";
import { styles } from "components/Resume/ResumePDF/styles";
import type { ResumeCustom } from "lib/redux/types";

export const ResumePDFCustom = ({
  heading,
  custom,
  themeColor,
  showBulletPoints,
}: {
  heading: string;
  custom: ResumeCustom;
  themeColor: string;
  showBulletPoints: boolean;
}) => {
  const { descriptions } = custom;

  return (
    <ResumePDFSection themeColor={themeColor} heading={heading}>
      <View style={{ ...styles.flexCol }}>
        <ResumePDFBulletList
          items={descriptions}
          showBulletPoints={showBulletPoints}
        />
      </View>
    </ResumePDFSection>
  );
};


================================================
FILE: src/app/components/Resume/ResumePDF/ResumePDFEducation.tsx
================================================
import { View } from "@react-pdf/renderer";
import {
  ResumePDFBulletList,
  ResumePDFSection,
  ResumePDFText,
} from "components/Resume/ResumePDF/common";
import { styles, spacing } from "components/Resume/ResumePDF/styles";
import type { ResumeEducation } from "lib/redux/types";

export const ResumePDFEducation = ({
  heading,
  educations,
  themeColor,
  showBulletPoints,
}: {
  heading: string;
  educations: ResumeEducation[];
  themeColor: string;
  showBulletPoints: boolean;
}) => {
  return (
    <ResumePDFSection themeColor={themeColor} heading={heading}>
      {educations.map(
        ({ school, degree, date, gpa, descriptions = [] }, idx) => {
          // Hide school name if it is the same as the previous school
          const hideSchoolName =
            idx > 0 && school === educations[idx - 1].school;
          const showDescriptions = descriptions.join() !== "";

          return (
            <View key={idx}>
              {!hideSchoolName && (
                <ResumePDFText bold={true}>{school}</ResumePDFText>
              )}
              <View
                style={{
                  ...styles.flexRowBetween,
                  marginTop: hideSchoolName
                    ? "-" + spacing["1"]
                    : spacing["1.5"],
                }}
              >
                <ResumePDFText>{`${
                  gpa
                    ? `${degree} - ${Number(gpa) ? gpa + " GPA" : gpa}`
                    : degree
                }`}</ResumePDFText>
                <ResumePDFText>{date}</ResumePDFText>
              </View>
              {showDescriptions && (
                <View style={{ ...styles.flexCol, marginTop: spacing["1.5"] }}>
                  <ResumePDFBulletList
                    items={descriptions}
                    showBulletPoints={showBulletPoints}
                  />
                </View>
              )}
            </View>
          );
        }
      )}
    </ResumePDFSection>
  );
};


================================================
FILE: src/app/components/Resume/ResumePDF/ResumePDFProfile.tsx
================================================
import { View } from "@react-pdf/renderer";
import {
  ResumePDFIcon,
  type IconType,
} from "components/Resume/ResumePDF/common/ResumePDFIcon";
import { styles, spacing } from "components/Resume/ResumePDF/styles";
import {
  ResumePDFLink,
  ResumePDFSection,
  ResumePDFText,
} from "components/Resume/ResumePDF/common";
import type { ResumeProfile } from "lib/redux/types";

export const ResumePDFProfile = ({
  profile,
  themeColor,
  isPDF,
}: {
  profile: ResumeProfile;
  themeColor: string;
  isPDF: boolean;
}) => {
  const { name, email, phone, url, summary, location } = profile;
  const iconProps = { email, phone, location, url };

  return (
    <ResumePDFSection style={{ marginTop: spacing["4"] }}>
      <ResumePDFText
        bold={true}
        themeColor={themeColor}
        style={{ fontSize: "20pt" }}
      >
        {name}
      </ResumePDFText>
      {summary && <ResumePDFText>{summary}</ResumePDFText>}
      <View
        style={{
          ...styles.flexRowBetween,
          flexWrap: "wrap",
          marginTop: spacing["0.5"],
        }}
      >
        {Object.entries(iconProps).map(([key, value]) => {
          if (!value) return null;

          let iconType = key as IconType;
          if (key === "url") {
            if (value.includes("github")) {
              iconType = "url_github";
            } else if (value.includes("linkedin")) {
              iconType = "url_linkedin";
            }
          }

          const shouldUseLinkWrapper = ["email", "url", "phone"].includes(key);
          const Wrapper = ({ children }: { children: React.ReactNode }) => {
            if (!shouldUseLinkWrapper) return <>{children}</>;

            let src = "";
            switch (key) {
              case "email": {
                src = `mailto:${value}`;
                break;
              }
              case "phone": {
                src = `tel:${value.replace(/[^\d+]/g, "")}`; // Keep only + and digits
                break;
              }
              default: {
                src = value.startsWith("http") ? value : `https://${value}`;
              }
            }

            return (
              <ResumePDFLink src={src} isPDF={isPDF}>
                {children}
              </ResumePDFLink>
            );
          };

          return (
            <View
              key={key}
              style={{
                ...styles.flexRow,
                alignItems: "center",
                gap: spacing["1"],
              }}
            >
              <ResumePDFIcon type={iconType} isPDF={isPDF} />
              <Wrapper>
                <ResumePDFText>{value}</ResumePDFText>
              </Wrapper>
            </View>
          );
        })}
      </View>
    </ResumePDFSection>
  );
};


================================================
FILE: src/app/components/Resume/ResumePDF/ResumePDFProject.tsx
================================================
import { View } from "@react-pdf/renderer";
import {
  ResumePDFSection,
  ResumePDFBulletList,
  ResumePDFText,
} from "components/Resume/ResumePDF/common";
import { styles, spacing } from "components/Resume/ResumePDF/styles";
import type { ResumeProject } from "lib/redux/types";

export const ResumePDFProject = ({
  heading,
  projects,
  themeColor,
}: {
  heading: string;
  projects: ResumeProject[];
  themeColor: string;
}) => {
  return (
    <ResumePDFSection themeColor={themeColor} heading={heading}>
      {projects.map(({ project, date, descriptions }, idx) => (
        <View key={idx}>
          <View
            style={{
              ...styles.flexRowBetween,
              marginTop: spacing["0.5"],
            }}
          >
            <ResumePDFText bold={true}>{project}</ResumePDFText>
            <ResumePDFText>{date}</ResumePDFText>
          </View>
          <View style={{ ...styles.flexCol, marginTop: spacing["0.5"] }}>
            <ResumePDFBulletList items={descriptions} />
          </View>
        </View>
      ))}
    </ResumePDFSection>
  );
};


================================================
FILE: src/app/components/Resume/ResumePDF/ResumePDFSkills.tsx
================================================
import { View } from "@react-pdf/renderer";
import {
  ResumePDFSection,
  ResumePDFBulletList,
  ResumeFeaturedSkill,
} from "components/Resume/ResumePDF/common";
import { styles, spacing } from "components/Resume/ResumePDF/styles";
import type { ResumeSkills } from "lib/redux/types";

export const ResumePDFSkills = ({
  heading,
  skills,
  themeColor,
  showBulletPoints,
}: {
  heading: string;
  skills: ResumeSkills;
  themeColor: string;
  showBulletPoints: boolean;
}) => {
  const { descriptions, featuredSkills } = skills;
  const featuredSkillsWithText = featuredSkills.filter((item) => item.skill);
  const featuredSkillsPair = [
    [featuredSkillsWithText[0], featuredSkillsWithText[3]],
    [featuredSkillsWithText[1], featuredSkillsWithText[4]],
    [featuredSkillsWithText[2], featuredSkillsWithText[5]],
  ];

  return (
    <ResumePDFSection themeColor={themeColor} heading={heading}>
      {featuredSkillsWithText.length > 0 && (
        <View style={{ ...styles.flexRowBetween, marginTop: spacing["0.5"] }}>
          {featuredSkillsPair.map((pair, idx) => (
            <View
              key={idx}
              style={{
                ...styles.flexCol,
              }}
            >
              {pair.map((featuredSkill, idx) => {
                if (!featuredSkill) return null;
                return (
                  <ResumeFeaturedSkill
                    key={idx}
                    skill={featuredSkill.skill}
                    rating={featuredSkill.rating}
                    themeColor={themeColor}
                    style={{
                      justifyContent: "flex-end",
                    }}
                  />
                );
              })}
            </View>
          ))}
        </View>
      )}
      <View style={{ ...styles.flexCol }}>
        <ResumePDFBulletList
          items={descriptions}
          showBulletPoints={showBulletPoints}
        />
      </View>
    </ResumePDFSection>
  );
};


================================================
FILE: src/app/components/Resume/ResumePDF/ResumePDFWorkExperience.tsx
================================================
import { View } from "@react-pdf/renderer";
import {
  ResumePDFSection,
  ResumePDFBulletList,
  ResumePDFText,
} from "components/Resume/ResumePDF/common";
import { styles, spacing } from "components/Resume/ResumePDF/styles";
import type { ResumeWorkExperience } from "lib/redux/types";

export const ResumePDFWorkExperience = ({
  heading,
  workExperiences,
  themeColor,
}: {
  heading: string;
  workExperiences: ResumeWorkExperience[];
  themeColor: string;
}) => {
  return (
    <ResumePDFSection themeColor={themeColor} heading={heading}>
      {workExperiences.map(({ company, jobTitle, date, descriptions }, idx) => {
        // Hide company name if it is the same as the previous company
        const hideCompanyName =
          idx > 0 && company === workExperiences[idx - 1].company;

        return (
          <View key={idx} style={idx !== 0 ? { marginTop: spacing["2"] } : {}}>
            {!hideCompanyName && (
              <ResumePDFText bold={true}>{company}</ResumePDFText>
            )}
            <View
              style={{
                ...styles.flexRowBetween,
                marginTop: hideCompanyName
                  ? "-" + spacing["1"]
                  : spacing["1.5"],
              }}
            >
              <ResumePDFText>{jobTitle}</ResumePDFText>
              <ResumePDFText>{date}</ResumePDFText>
            </View>
            <View style={{ ...styles.flexCol, marginTop: spacing["1.5"] }}>
              <ResumePDFBulletList items={descriptions} />
            </View>
          </View>
        );
      })}
    </ResumePDFSection>
  );
};


================================================
FILE: src/app/components/Resume/ResumePDF/common/ResumePDFIcon.tsx
================================================
import { Svg, Path } from "@react-pdf/renderer";
import { styles } from "components/Resume/ResumePDF/styles";

/**
 * Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License
 * - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
 */
const EMAIL_PATH_D =
  "M64 112c-8.8 0-16 7.2-16 16v22.1L220.5 291.7c20.7 17 50.4 17 71.1 0L464 150.1V128c0-8.8-7.2-16-16-16H64zM48 212.2V384c0 8.8 7.2 16 16 16H448c8.8 0 16-7.2 16-16V212.2L322 328.8c-38.4 31.5-93.7 31.5-132 0L48 212.2zM0 128C0 92.7 28.7 64 64 64H448c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128z";
const PHONE_PATH_D =
  "M164.9 24.6c-7.7-18.6-28-28.5-47.4-23.2l-88 24C12.1 30.2 0 46 0 64C0 311.4 200.6 512 448 512c18 0 33.8-12.1 38.6-29.5l24-88c5.3-19.4-4.6-39.7-23.2-47.4l-96-40c-16.3-6.8-35.2-2.1-46.3 11.6L304.7 368C234.3 334.7 177.3 277.7 144 207.3L193.3 167c13.7-11.2 18.4-30 11.6-46.3l-40-96z";
const LOCATION_PATH_D =
  "M215.7 499.2C267 435 384 279.4 384 192C384 86 298 0 192 0S0 86 0 192c0 87.4 117 243 168.3 307.2c12.3 15.3 35.1 15.3 47.4 0zM192 128a64 64 0 1 1 0 128 64 64 0 1 1 0-128z";
const URL_PATH_D =
  "M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z";
const GITHUB_PATH_D =
  "M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z";
const LINKEDIN_PATH_D =
  "M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z";
const TYPE_TO_PATH_D = {
  email: EMAIL_PATH_D,
  phone: PHONE_PATH_D,
  location: LOCATION_PATH_D,
  url: URL_PATH_D,
  url_github: GITHUB_PATH_D,
  url_linkedin: LINKEDIN_PATH_D,
} as const;

export type IconType =
  | "email"
  | "phone"
  | "location"
  | "url"
  | "url_github"
  | "url_linkedin";

export const ResumePDFIcon = ({
  type,
  isPDF,
}: {
  type: IconType;
  isPDF: boolean;
}) => {
  const pathD = TYPE_TO_PATH_D[type];
  if (isPDF) {
    return <PDFIcon pathD={pathD} />;
  }
  return <SVGIcon pathD={pathD} />;
};

const { width, height, fill } = styles.icon;

const PDFIcon = ({ pathD }: { pathD: string }) => (
  <Svg viewBox="0 0 512 512" style={{ width, height }}>
    <Path d={pathD} fill={fill} />
  </Svg>
);

const SVGIcon = ({ pathD }: { pathD: string }) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 512 512"
    style={{ width, height, fill }}
  >
    <path d={pathD} />
  </svg>
);


================================================
FILE: src/app/components/Resume/ResumePDF/common/SuppressResumePDFErrorMessage.tsx
================================================
"use client";

/**
 * Suppress ResumePDF development errors.
 * See ResumePDF doc string for context.
 */
if (typeof window !== "undefined" && window.location.hostname === "localhost") {
  const consoleError = console.error;
  const SUPPRESSED_WARNINGS = ["DOCUMENT", "PAGE", "TEXT", "VIEW"];
  console.error = function filterWarnings(msg, ...args) {
    if (!SUPPRESSED_WARNINGS.some((entry) => args[0]?.includes(entry))) {
      consoleError(msg, ...args);
    }
  };
}

export const SuppressResumePDFErrorMessage = () => {
  return <></>;
};


================================================
FILE: src/app/components/Resume/ResumePDF/common/index.tsx
================================================
import { Text, View, Link } from "@react-pdf/renderer";
import type { Style } from "@react-pdf/types";
import { styles, spacing } from "components/Resume/ResumePDF/styles";
import { DEBUG_RESUME_PDF_FLAG } from "lib/constants";
import { DEFAULT_FONT_COLOR } from "lib/redux/settingsSlice";

export const ResumePDFSection = ({
  themeColor,
  heading,
  style = {},
  children,
}: {
  themeColor?: string;
  heading?: string;
  style?: Style;
  children: React.ReactNode;
}) => (
  <View
    style={{
      ...styles.flexCol,
      gap: spacing["2"],
      marginTop: spacing["5"],
      ...style,
    }}
  >
    {heading && (
      <View style={{ ...styles.flexRow, alignItems: "center" }}>
        {themeColor && (
          <View
            style={{
              height: "3.75pt",
              width: "30pt",
              backgroundColor: themeColor,
              marginRight: spacing["3.5"],
            }}
            debug={DEBUG_RESUME_PDF_FLAG}
          />
        )}
        <Text
          style={{
            fontWeight: "bold",
            letterSpacing: "0.3pt", // tracking-wide -> 0.025em * 12 pt = 0.3pt
          }}
          debug={DEBUG_RESUME_PDF_FLAG}
        >
          {heading}
        </Text>
      </View>
    )}
    {children}
  </View>
);

export const ResumePDFText = ({
  bold = false,
  themeColor,
  style = {},
  children,
}: {
  bold?: boolean;
  themeColor?: string;
  style?: Style;
  children: React.ReactNode;
}) => {
  return (
    <Text
      style={{
        color: themeColor || DEFAULT_FONT_COLOR,
        fontWeight: bold ? "bold" : "normal",
        ...style,
      }}
      debug={DEBUG_RESUME_PDF_FLAG}
    >
      {children}
    </Text>
  );
};

export const ResumePDFBulletList = ({
  items,
  showBulletPoints = true,
}: {
  items: string[];
  showBulletPoints?: boolean;
}) => {
  return (
    <>
      {items.map((item, idx) => (
        <View style={{ ...styles.flexRow }} key={idx}>
          {showBulletPoints && (
            <ResumePDFText
              style={{
                paddingLeft: spacing["2"],
                paddingRight: spacing["2"],
                lineHeight: "1.3",
              }}
              bold={true}
            >
              {"•"}
            </ResumePDFText>
          )}
          {/* A breaking change was introduced causing text layout to be wider than node's width
              https://github.com/diegomura/react-pdf/issues/2182. flexGrow & flexBasis fixes it */}
          <ResumePDFText
            style={{ lineHeight: "1.3", flexGrow: 1, flexBasis: 0 }}
          >
            {item}
          </ResumePDFText>
        </View>
      ))}
    </>
  );
};

export const ResumePDFLink = ({
  src,
  isPDF,
  children,
}: {
  src: string;
  isPDF: boolean;
  children: React.ReactNode;
}) => {
  if (isPDF) {
    return (
      <Link src={src} style={{ textDecoration: "none" }}>
        {children}
      </Link>
    );
  }
  return (
    <a
      href={src}
      style={{ textDecoration: "none" }}
      target="_blank"
      rel="noreferrer"
    >
      {children}
    </a>
  );
};

export const ResumeFeaturedSkill = ({
  skill,
  rating,
  themeColor,
  style = {},
}: {
  skill: string;
  rating: number;
  themeColor: string;
  style?: Style;
}) => {
  const numCircles = 5;

  return (
    <View style={{ ...styles.flexRow, alignItems: "center", ...style }}>
      <ResumePDFText style={{ marginRight: spacing[0.5] }}>
        {skill}
      </ResumePDFText>
      {[...Array(numCircles)].map((_, idx) => (
        <View
          key={idx}
          style={{
            height: "9pt",
            width: "9pt",
            marginLeft: "2.25pt",
            backgroundColor: rating >= idx ? themeColor : "#d9d9d9",
            borderRadius: "100%",
          }}
        />
      ))}
    </View>
  );
};


================================================
FILE: src/app/components/Resume/ResumePDF/index.tsx
================================================
import { Page, View, Document } from "@react-pdf/renderer";
import { styles, spacing } from "components/Resume/ResumePDF/styles";
import { ResumePDFProfile } from "components/Resume/ResumePDF/ResumePDFProfile";
import { ResumePDFWorkExperience } from "components/Resume/ResumePDF/ResumePDFWorkExperience";
import { ResumePDFEducation } from "components/Resume/ResumePDF/ResumePDFEducation";
import { ResumePDFProject } from "components/Resume/ResumePDF/ResumePDFProject";
import { ResumePDFSkills } from "components/Resume/ResumePDF/ResumePDFSkills";
import { ResumePDFCustom } from "components/Resume/ResumePDF/ResumePDFCustom";
import { DEFAULT_FONT_COLOR } from "lib/redux/settingsSlice";
import type { Settings, ShowForm } from "lib/redux/settingsSlice";
import type { Resume } from "lib/redux/types";
import { SuppressResumePDFErrorMessage } from "components/Resume/ResumePDF/common/SuppressResumePDFErrorMessage";

/**
 * Note: ResumePDF is supposed to be rendered inside PDFViewer. However,
 * PDFViewer is rendered too slow and has noticeable delay as you enter
 * the resume form, so we render it without PDFViewer to make it render
 * instantly. There are 2 drawbacks with this approach:
 * 1. Not everything works out of box if not rendered inside PDFViewer,
 *    e.g. svg doesn't work, so it takes in a isPDF flag that maps react
 *    pdf element to the correct dom element.
 * 2. It throws a lot of errors in console log, e.g. "<VIEW /> is using incorrect
 *    casing. Use PascalCase for React components, or lowercase for HTML elements."
 *    in development, causing a lot of noises. We can possibly workaround this by
 *    mapping every react pdf element to a dom element, but for now, we simply
 *    suppress these messages in <SuppressResumePDFErrorMessage />.
 *    https://github.com/diegomura/react-pdf/issues/239#issuecomment-487255027
 */
export const ResumePDF = ({
  resume,
  settings,
  isPDF = false,
}: {
  resume: Resume;
  settings: Settings;
  isPDF?: boolean;
}) => {
  const { profile, workExperiences, educations, projects, skills, custom } =
    resume;
  const { name } = profile;
  const {
    fontFamily,
    fontSize,
    documentSize,
    formToHeading,
    formToShow,
    formsOrder,
    showBulletPoints,
  } = settings;
  const themeColor = settings.themeColor || DEFAULT_FONT_COLOR;

  const showFormsOrder = formsOrder.filter((form) => formToShow[form]);

  const formTypeToComponent: { [type in ShowForm]: () => JSX.Element } = {
    workExperiences: () => (
      <ResumePDFWorkExperience
        heading={formToHeading["workExperiences"]}
        workExperiences={workExperiences}
        themeColor={themeColor}
      />
    ),
    educations: () => (
      <ResumePDFEducation
        heading={formToHeading["educations"]}
        educations={educations}
        themeColor={themeColor}
        showBulletPoints={showBulletPoints["educations"]}
      />
    ),
    projects: () => (
      <ResumePDFProject
        heading={formToHeading["projects"]}
        projects={projects}
        themeColor={themeColor}
      />
    ),
    skills: () => (
      <ResumePDFSkills
        heading={formToHeading["skills"]}
        skills={skills}
        themeColor={themeColor}
        showBulletPoints={showBulletPoints["skills"]}
      />
    ),
    custom: () => (
      <ResumePDFCustom
        heading={formToHeading["custom"]}
        custom={custom}
        themeColor={themeColor}
        showBulletPoints={showBulletPoints["custom"]}
      />
    ),
  };

  return (
    <>
      <Document title={`${name} Resume`} author={name} producer={"OpenResume"}>
        <Page
          size={documentSize === "A4" ? "A4" : "LETTER"}
          style={{
            ...styles.flexCol,
            color: DEFAULT_FONT_COLOR,
            fontFamily,
            fontSize: fontSize + "pt",
          }}
        >
          {Boolean(settings.themeColor) && (
            <View
              style={{
                width: spacing["full"],
                height: spacing[3.5],
                backgroundColor: themeColor,
              }}
            />
          )}
          <View
            style={{
              ...styles.flexCol,
              padding: `${spacing[0]} ${spacing[20]}`,
            }}
          >
            <ResumePDFProfile
              profile={profile}
              themeColor={themeColor}
              isPDF={isPDF}
            />
            {showFormsOrder.map((form) => {
              const Component = formTypeToComponent[form];
              return <Component key={form} />;
            })}
          </View>
        </Page>
      </Document>
      <SuppressResumePDFErrorMessage />
    </>
  );
};


================================================
FILE: src/app/components/Resume/ResumePDF/styles.ts
================================================
import { StyleSheet } from "@react-pdf/renderer";

// Tailwindcss Spacing Design System: https://tailwindcss.com/docs/theme#spacing
// It is converted from rem to pt (1rem = 12pt) since https://react-pdf.org/styling only accepts pt unit
export const spacing = {
  0: "0",
  0.5: "1.5pt",
  1: "3pt",
  1.5: "4.5pt",
  2: "6pt",
  2.5: "7.5pt",
  3: "9pt",
  3.5: "10.5pt",
  4: "12pt",
  5: "15pt",
  6: "18pt",
  7: "21pt",
  8: "24pt",
  9: "27pt",
  10: "30pt",
  11: "33pt",
  12: "36pt",
  14: "42pt",
  16: "48pt",
  20: "60pt",
  24: "72pt",
  28: "84pt",
  32: "96pt",
  36: "108pt",
  40: "120pt",
  44: "132pt",
  48: "144pt",
  52: "156pt",
  56: "168pt",
  60: "180pt",
  64: "192pt",
  72: "216pt",
  80: "240pt",
  96: "288pt",
  full: "100%",
} as const;

export const styles = StyleSheet.create({
  flexRow: {
    display: "flex",
    flexDirection: "row",
  },
  flexRowBetween: {
    display: "flex",
    flexDirection: "row",
    justifyContent: "space-between",
  },
  flexCol: {
    display: "flex",
    flexDirection: "column",
  },
  icon: {
    width: "13pt",
    height: "13pt",
    fill: "#525252", // text-neutral-600
  },
});


================================================
FILE: src/app/components/Resume/hooks.tsx
================================================
import { useEffect, useState } from "react";
import { A4_HEIGHT_PX, LETTER_HEIGHT_PX } from "lib/constants";
import { getPxPerRem } from "lib/get-px-per-rem";
import { CSS_VARIABLES } from "globals-css";

/**
 * useSetDefaultScale sets the default scale of the resume on load.
 *
 * It computes the scale based on current screen height and derives the default
 * resume height by subtracting the screen height from the total heights of top
 * nav bar, resume control bar, and resume top & bottom padding.
 */
export const useSetDefaultScale = ({
  setScale,
  documentSize,
}: {
  setScale: (scale: number) => void;
  documentSize: string;
}) => {
  const [scaleOnResize, setScaleOnResize] = useState(true);

  useEffect(() => {
    const getDefaultScale = () => {
      const screenHeightPx = window.innerHeight;
      const PX_PER_REM = getPxPerRem();
      const screenHeightRem = screenHeightPx / PX_PER_REM;
      const topNavBarHeightRem = parseFloat(
        CSS_VARIABLES["--top-nav-bar-height"]
      );
      const resumeControlBarHeight = parseFloat(
        CSS_VARIABLES["--resume-control-bar-height"]
      );
      const resumePadding = parseFloat(CSS_VARIABLES["--resume-padding"]);
      const topAndBottomResumePadding = resumePadding * 2;
      const defaultResumeHeightRem =
        screenHeightRem -
        topNavBarHeightRem -
        resumeControlBarHeight -
        topAndBottomResumePadding;
      const resumeHeightPx = defaultResumeHeightRem * PX_PER_REM;
      const height = documentSize === "A4" ? A4_HEIGHT_PX : LETTER_HEIGHT_PX;
      const defaultScale = Math.round((resumeHeightPx / height) * 100) / 100;
      return defaultScale;
    };

    const setDefaultScale = () => {
      const defaultScale = getDefaultScale();
      setScale(defaultScale);
    };

    if (scaleOnResize) {
      setDefaultScale();
      window.addEventListener("resize", setDefaultScale);
    }

    return () => {
      window.removeEventListener("resize", setDefaultScale);
    };
  }, [setScale, scaleOnResize, documentSize]);

  return { scaleOnResize, setScaleOnResize };
};


================================================
FILE: src/app/components/Resume/index.tsx
================================================
"use client";
import { useState, useMemo } from "react";
import { ResumeIframeCSR } from "components/Resume/ResumeIFrame";
import { ResumePDF } from "components/Resume/ResumePDF";
import {
  ResumeControlBarCSR,
  ResumeControlBarBorder,
} from "components/Resume/ResumeControlBar";
import { FlexboxSpacer } from "components/FlexboxSpacer";
import { useAppSelector } from "lib/redux/hooks";
import { selectResume } from "lib/redux/resumeSlice";
import { selectSettings } from "lib/redux/settingsSlice";
import { DEBUG_RESUME_PDF_FLAG } from "lib/constants";
import {
  useRegisterReactPDFFont,
  useRegisterReactPDFHyphenationCallback,
} from "components/fonts/hooks";
import { NonEnglishFontsCSSLazyLoader } from "components/fonts/NonEnglishFontsCSSLoader";

export const Resume = () => {
  const [scale, setScale] = useState(0.8);
  const resume = useAppSelector(selectResume);
  const settings = useAppSelector(selectSettings);
  const document = useMemo(
    () => <ResumePDF resume={resume} settings={settings} isPDF={true} />,
    [resume, settings]
  );

  useRegisterReactPDFFont();
  useRegisterReactPDFHyphenationCallback(settings.fontFamily);

  return (
    <>
      <NonEnglishFontsCSSLazyLoader />
      <div className="relative flex justify-center md:justify-start">
        <FlexboxSpacer maxWidth={50} className="hidden md:block" />
        <div className="relative">
          <section className="h-[calc(100vh-var(--top-nav-bar-height)-var(--resume-control-bar-height))] overflow-hidden md:p-[var(--resume-padding)]">
            <ResumeIframeCSR
              documentSize={settings.documentSize}
              scale={scale}
              enablePDFViewer={DEBUG_RESUME_PDF_FLAG}
            >
              <ResumePDF
                resume={resume}
                settings={settings}
                isPDF={DEBUG_RESUME_PDF_FLAG}
              />
            </ResumeIframeCSR>
          </section>
          <ResumeControlBarCSR
            scale={scale}
            setScale={setScale}
            documentSize={settings.documentSize}
            document={document}
            fileName={resume.profile.name + " - Resume"}
          />
        </div>
        <ResumeControlBarBorder />
      </div>
    </>
  );
};


================================================
FILE: src/app/components/ResumeDropzone.tsx
================================================
import { useState } from "react";
import { LockClosedIcon } from "@heroicons/react/24/solid";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { parseResumeFromPdf } from "lib/parse-resume-from-pdf";
import {
  getHasUsedAppBefore,
  saveStateToLocalStorage,
} from "lib/redux/local-storage";
import { type ShowForm, initialSettings } from "lib/redux/settingsSlice";
import { useRouter } from "next/navigation";
import addPdfSrc from "public/assets/add-pdf.svg";
import Image from "next/image";
import { cx } from "lib/cx";
import { deepClone } from "lib/deep-clone";

const defaultFileState = {
  name: "",
  size: 0,
  fileUrl: "",
};

export const ResumeDropzone = ({
  onFileUrlChange,
  className,
  playgroundView = false,
}: {
  onFileUrlChange: (fileUrl: string) => void;
  className?: string;
  playgroundView?: boolean;
}) => {
  const [file, setFile] = useState(defaultFileState);
  const [isHoveredOnDropzone, setIsHoveredOnDropzone] = useState(false);
  const [hasNonPdfFile, setHasNonPdfFile] = useState(false);
  const router = useRouter();

  const hasFile = Boolean(file.name);

  const setNewFile = (newFile: File) => {
    if (file.fileUrl) {
      URL.revokeObjectURL(file.fileUrl);
    }

    const { name, size } = newFile;
    const fileUrl = URL.createObjectURL(newFile);
    setFile({ name, size, fileUrl });
    onFileUrlChange(fileUrl);
  };

  const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    const newFile = event.dataTransfer.files[0];
    if (newFile.name.endsWith(".pdf")) {
      setHasNonPdfFile(false);
      setNewFile(newFile);
    } else {
      setHasNonPdfFile(true);
    }
    setIsHoveredOnDropzone(false);
  };

  const onInputChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    if (!files) return;

    const newFile = files[0];
    setNewFile(newFile);
  };

  const onRemove = () => {
    setFile(defaultFileState);
    onFileUrlChange("");
  };

  const onImportClick = async () => {
    const resume = await parseResumeFromPdf(file.fileUrl);
    const settings = deepClone(initialSettings);

    // Set formToShow settings based on uploaded resume if users have used the app before
    if (getHasUsedAppBefore()) {
      const sections = Object.keys(settings.formToShow) as ShowForm[];
      const sectionToFormToShow: Record<ShowForm, boolean> = {
        workExperiences: resume.workExperiences.length > 0,
        educations: resume.educations.length > 0,
        projects: resume.projects.length > 0,
        skills: resume.skills.descriptions.length > 0,
        custom: resume.custom.descriptions.length > 0,
      };
      for (const section of sections) {
        settings.formToShow[section] = sectionToFormToShow[section];
      }
    }

    saveStateToLocalStorage({ resume, settings });
    router.push("/resume-builder");
  };

  return (
    <div
      className={cx(
        "flex justify-center rounded-md border-2 border-dashed border-gray-300 px-6 ",
        isHoveredOnDropzone && "border-sky-400",
        playgroundView ? "pb-6 pt-4" : "py-12",
        className
      )}
      onDragOver={(event) => {
        event.preventDefault();
        setIsHoveredOnDropzone(true);
      }}
      onDragLeave={() => setIsHoveredOnDropzone(false)}
      onDrop={onDrop}
    >
      <div
        className={cx(
          "text-center",
          playgroundView ? "space-y-2" : "space-y-3"
        )}
      >
        {!playgroundView && (
          <Image
            src={addPdfSrc}
            className="mx-auto h-14 w-14"
            alt="Add pdf"
            aria-hidden="true"
            priority
          />
        )}
        {!hasFile ? (
          <>
            <p
              className={cx(
                "pt-3 text-gray-700",
                !playgroundView && "text-lg font-semibold"
              )}
            >
              Browse a pdf file or drop it here
            </p>
            <p className="flex text-sm text-gray-500">
              <LockClosedIcon className="mr-1 mt-1 h-3 w-3 text-gray-400" />
              File data is used locally and never leaves your browser
            </p>
          </>
        ) : (
          <div className="flex items-center justify-center gap-3 pt-3">
            <div className="pl-7 font-semibold text-gray-900">
              {file.name} - {getFileSizeString(file.size)}
            </div>
            <button
              type="button"
              className="outline-theme-blue rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500"
              title="Remove file"
              onClick={onRemove}
            >
              <XMarkIcon className="h-6 w-6" />
            </button>
          </div>
        )}
        <div className="pt-4">
          {!hasFile ? (
            <>
              <label
                className={cx(
                  "within-outline-theme-purple cursor-pointer rounded-full px-6 pb-2.5 pt-2 font-semibold shadow-sm",
                  playgroundView ? "border" : "bg-primary"
                )}
              >
                Browse file
                <input
                  type="file"
                  className="sr-only"
                  accept=".pdf"
                  onChange={onInputChange}
                />
              </label>
              {hasNonPdfFile && (
                <p className="mt-6 text-red-400">Only pdf file is supported</p>
              )}
            </>
          ) : (
            <>
              {!playgroundView && (
                <button
                  type="button"
                  className="btn-primary"
                  onClick={onImportClick}
                >
                  Import and Continue <span aria-hidden="true">→</span>
                </button>
              )}
              <p className={cx(" text-gray-500", !playgroundView && "mt-6")}>
                Note: {!playgroundView ? "Import" : "Parser"} works best on
                single column resume
              </p>
            </>
          )}
        </div>
      </div>
    </div>
  );
};

const getFileSizeString = (fileSizeB: number) => {
  const fileSizeKB = fileSizeB / 1024;
  const fileSizeMB = fileSizeKB / 1024;
  if (fileSizeKB < 1000) {
    return fileSizeKB.toPrecision(3) + " KB";
  } else {
    return fileSizeMB.toPrecision(3) + " MB";
  }
};


================================================
FILE: src/app/components/ResumeForm/CustomForm.tsx
================================================
import { Form } from "components/ResumeForm/Form";
import { BulletListIconButton } from "components/ResumeForm/Form/IconButton";
import { BulletListTextarea } from "components/ResumeForm/Form/InputGroup";
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
import { changeCustom, selectCustom } from "lib/redux/resumeSlice";
import {
  selectShowBulletPoints,
  changeShowBulletPoints,
} from "lib/redux/settingsSlice";

export const CustomForm = () => {
  const custom = useAppSelector(selectCustom);
  const dispatch = useAppDispatch();
  const { descriptions } = custom;
  const form = "custom";
  const showBulletPoints = useAppSelector(selectShowBulletPoints(form));

  const handleCustomChange = (field: "descriptions", value: string[]) => {
    dispatch(changeCustom({ field, value }));
  };

  const handleShowBulletPoints = (value: boolean) => {
    dispatch(changeShowBulletPoints({ field: form, value }));
  };

  return (
    <Form form={form}>
      <div className="col-span-full grid grid-cols-6 gap-3">
        <div className="relative col-span-full">
          <BulletListTextarea
            label="Custom Textbox"
            labelClassName="col-span-full"
            name="descriptions"
            placeholder="Bullet points"
            value={descriptions}
            onChange={handleCustomChange}
            showBulletPoints={showBulletPoints}
          />
          <div className="absolute left-[7.7rem] top-[0.07rem]">
            <BulletListIconButton
              showBulletPoints={showBulletPoints}
              onClick={handleShowBulletPoints}
            />
          </div>
        </div>
      </div>
    </Form>
  );
};


================================================
FILE: src/app/components/ResumeForm/EducationsForm.tsx
================================================
import { Form, FormSection } from "components/ResumeForm/Form";
import {
  BulletListTextarea,
  Input,
} from "components/ResumeForm/Form/InputGroup";
import { BulletListIconButton } from "components/ResumeForm/Form/IconButton";
import type { CreateHandleChangeArgsWithDescriptions } from "components/ResumeForm/types";
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
import { changeEducations, selectEducations } from "lib/redux/resumeSlice";
import type { ResumeEducation } from "lib/redux/types";
import {
  changeShowBulletPoints,
  selectShowBulletPoints,
} from "lib/redux/settingsSlice";

export const EducationsForm = () => {
  const educations = useAppSelector(selectEducations);
  const dispatch = useAppDispatch();
  const showDelete = educations.length > 1;
  const form = "educations";
  const showBulletPoints = useAppSelector(selectShowBulletPoints(form));

  return (
    <Form form={form} addButtonText="Add School">
      {educations.map(({ school, degree, gpa, date, descriptions }, idx) => {
        const handleEducationChange = (
          ...[
            field,
            value,
          ]: CreateHandleChangeArgsWithDescriptions<ResumeEducation>
        ) => {
          dispatch(changeEducations({ idx, field, value } as any));
        };

        const handleShowBulletPoints = (value: boolean) => {
          dispatch(changeShowBulletPoints({ field: form, value }));
        };

        const showMoveUp = idx !== 0;
        const showMoveDown = idx !== educations.length - 1;

        return (
          <FormSection
            key={idx}
            form="educations"
            idx={idx}
            showMoveUp={showMoveUp}
            showMoveDown={showMoveDown}
            showDelete={showDelete}
            deleteButtonTooltipText="Delete school"
          >
            <Input
              label="School"
              labelClassName="col-span-4"
              name="school"
              placeholder="Cornell University"
              value={school}
              onChange={handleEducationChange}
            />
            <Input
              label="Date"
              labelClassName="col-span-2"
              name="date"
              placeholder="May 2018"
              value={date}
              onChange={handleEducationChange}
            />
            <Input
              label="Degree & Major"
              labelClassName="col-span-4"
              name="degree"
              placeholder="Bachelor of Science in Computer Engineering"
              value={degree}
              onChange={handleEducationChange}
            />
            <Input
              label="GPA"
              labelClassName="col-span-2"
              name="gpa"
              placeholder="3.81"
              value={gpa}
              onChange={handleEducationChange}
            />
            <div className="relative col-span-full">
              <BulletListTextarea
                label="Additional Information (Optional)"
                labelClassName="col-span-full"
                name="descriptions"
                placeholder="Free paragraph space to list out additional activities, courses, awards etc"
                value={descriptions}
                onChange={handleEducationChange}
                showBulletPoints={showBulletPoints}
              />
              <div className="absolute left-[15.6rem] top-[0.07rem]">
                <BulletListIconButton
                  showBulletPoints={showBulletPoints}
                  onClick={handleShowBulletPoints}
                />
              </div>
            </div>
          </FormSection>
        );
      })}
    </Form>
  );
};


================================================
FILE: src/app/components/ResumeForm/Form/FeaturedSkillInput.tsx
================================================
import React, { useState } from "react";
import { INPUT_CLASS_NAME } from "components/ResumeForm/Form/InputGroup";

export const FeaturedSkillInput = ({
  skill,
  rating,
  setSkillRating,
  placeholder,
  className,
  circleColor,
}: {
  skill: string;
  rating: number;
  setSkillRating: (skill: string, rating: number) => void;
  placeholder: string;
  className?: string;
  circleColor?: string;
}) => {
  return (
    <div className={`flex ${className}`}>
      <input
        type="text"
        value={skill}
        placeholder={placeholder}
        onChange={(e) => setSkillRating(e.target.value, rating)}
        className={INPUT_CLASS_NAME}
      />
      <CircleRating
        rating={rating}
        setRating={(newRating) => setSkillRating(skill, newRating)}
        circleColor={circleColor}
      />
    </div>
  );
};

const CircleRating = ({
  rating,
  setRating,
  circleColor = "#38bdf8",
}: {
  rating: number;
  setRating: (rating: number) => void;
  circleColor?: string;
}) => {
  const numCircles = 5;
  const [hoverRating, setHoverRating] = useState<number | null>(null);

  return (
    <div className="flex items-center p-2">
      {[...Array(numCircles)].map((_, idx) => (
        <div
          className={`cursor-pointer p-0.5`}
          key={idx}
          onClick={() => setRating(idx)}
          onMouseEnter={() => setHoverRating(idx)}
          onMouseLeave={() => setHoverRating(null)}
        >
          <div
            className="h-5 w-5 rounded-full transition-transform duration-200 hover:scale-[120%] "
            style={{
              backgroundColor:
                (hoverRating !== null && hoverRating >= idx) ||
                (hoverRating === null && rating >= idx)
                  ? circleColor
                  : "#d1d5db", //gray-300
            }}
          />
        </div>
      ))}
    </div>
  );
};


================================================
FILE: src/app/components/ResumeForm/Form/IconButton.tsx
================================================
import { IconButton } from "components/Button";
import {
  EyeIcon,
  EyeSlashIcon,
  ArrowSmallUpIcon,
  ArrowSmallDownIcon,
  TrashIcon,
  ListBulletIcon,
} from "@heroicons/react/24/outline";

export const ShowIconButton = ({
  show,
  setShow,
}: {
  show: boolean;
  setShow: (show: boolean) => void;
}) => {
  const tooltipText = show ? "Hide section" : "Show section";
  const onClick = () => {
    setShow(!show);
  };
  const Icon = show ? EyeIcon : EyeSlashIcon;

  return (
    <IconButton onClick={onClick} tooltipText={tooltipText}>
      <Icon className="h-6 w-6 text-gray-400" aria-hidden="true" />
      <span className="sr-only">{tooltipText}</span>
    </IconButton>
  );
};

type MoveIconButtonType = "up" | "down";
export const MoveIconButton = ({
  type,
  size = "medium",
  onClick,
}: {
  type: MoveIconButtonType;
  size?: "small" | "medium";
  onClick: (type: MoveIconButtonType) => void;
}) => {
  const tooltipText = type === "up" ? "Move up" : "Move down";
  const sizeClassName = size === "medium" ? "h-6 w-6" : "h-4 w-4";
  const Icon = type === "up" ? ArrowSmallUpIcon : ArrowSmallDownIcon;

  return (
    <IconButton
      onClick={() => onClick(type)}
      tooltipText={tooltipText}
      size={size}
    >
      <Icon className={`${sizeClassName} text-gray-400`} aria-hidden="true" />
      <span className="sr-only">{tooltipText}</span>
    </IconButton>
  );
};

export const DeleteIconButton = ({
  onClick,
  tooltipText,
}: {
  onClick: () => void;
  tooltipText: string;
}) => {
  return (
    <IconButton onClick={onClick} tooltipText={tooltipText} size="small">
      <TrashIcon className="h-4 w-4 text-gray-400" aria-hidden="true" />
      <span className="sr-only">{tooltipText}</span>
    </IconButton>
  );
};

export const BulletListIconButton = ({
  onClick,
  showBulletPoints,
}: {
  onClick: (newShowBulletPoints: boolean) => void;
  showBulletPoints: boolean;
}) => {
  const tooltipText = showBulletPoints
    ? "Hide bullet points"
    : "Show bullet points";

  return (
    <IconButton
      onClick={() => onClick(!showBulletPoints)}
      tooltipText={tooltipText}
      size="small"
      className={showBulletPoints ? "!bg-sky-100" : ""}
    >
      <ListBulletIcon
        className={`h-4 w-4 ${
          showBulletPoints ? "text-gray-700" : "text-gray-400"
        }`}
        aria-hidden="true"
      />
      <span className="sr-only">{tooltipText}</span>
    </IconButton>
  );
};


================================================
FILE: src/app/components/ResumeForm/Form/InputGroup.tsx
================================================
import { useState, useEffect } from "react";
import ContentEditable from "react-contenteditable";
import { useAutosizeTextareaHeight } from "lib/hooks/useAutosizeTextareaHeight";

interface InputProps<K extends string, V extends string | string[]> {
  label: string;
  labelClassName?: string;
  // name is passed in as a const string. Therefore, we make it a generic type so its type can
  // be more restricted as a const for the first argument in onChange
  name: K;
  value?: V;
  placeholder: string;
  onChange: (name: K, value: V) => void;
}

/**
 * InputGroupWrapper wraps a label element around a input children. This is preferable
 * than having input as a sibling since it makes clicking label auto focus input children
 */
export const InputGroupWrapper = ({
  label,
  className,
  children,
}: {
  label: string;
  className?: string;
  children?: React.ReactNode;
}) => (
  <label className={`text-base font-medium text-gray-700 ${className}`}>
    {label}
    {children}
  </label>
);

export const INPUT_CLASS_NAME =
  "mt-1 px-3 py-2 block w-full rounded-md border border-gray-300 text-gray-900 shadow-sm outline-none font-normal text-base";

export const Input = <K extends string>({
  name,
  value = "",
  placeholder,
  onChange,
  label,
  labelClassName,
}: InputProps<K, string>) => {
  return (
    <InputGroupWrapper label={label} className={labelClassName}>
      <input
        type="text"
        name={name}
        value={value}
        placeholder={placeholder}
        onChange={(e) => onChange(name, e.target.value)}
        className={INPUT_CLASS_NAME}
      />
    </InputGroupWrapper>
  );
};

export const Textarea = <T extends string>({
  label,
  labelClassName: wrapperClassName,
  name,
  value = "",
  placeholder,
  onChange,
}: InputProps<T, string>) => {
  const textareaRef = useAutosizeTextareaHeight({ value });

  return (
    <InputGroupWrapper label={label} className={wrapperClassName}>
      <textarea
        ref={textareaRef}
        name={name}
        className={`${INPUT_CLASS_NAME} resize-none overflow-hidden`}
        placeholder={placeholder}
        value={value}
        onChange={(e) => onChange(name, e.target.value)}
      />
    </InputGroupWrapper>
  );
};

export const BulletListTextarea = <T extends string>(
  props: InputProps<T, string[]> & { showBulletPoints?: boolean }
) => {
  const [showFallback, setShowFallback] = useState(false);

  useEffect(() => {
    const isFirefox = navigator.userAgent.includes("Firefox");
    const isSafari =
      navigator.userAgent.includes("Safari") &&
      !navigator.userAgent.includes("Chrome"); // Note that Chrome also includes Safari in its userAgent
    if (isFirefox || isSafari) {
      setShowFallback(true);
    }
  }, []);

  if (showFallback) {
    return <BulletListTextareaFallback {...props} />;
  }
  return <BulletListTextareaGeneral {...props} />;
};

/**
 * BulletListTextareaGeneral is a textarea where each new line starts with a bullet point.
 *
 * In its core, it uses a div with contentEditable set to True. However, when
 * contentEditable is True, user can paste in any arbitrary html and it would
 * render. So to make it behaves like a textarea, it strips down all html while
 * keeping only the text part.
 *
 * Reference: https://stackoverflow.com/a/74998090/7699841
 */
const BulletListTextareaGeneral = <T extends string>({
  label,
  labelClassName: wrapperClassName,
  name,
  value: bulletListStrings = [],
  placeholder,
  onChange,
  showBulletPoints = true,
}: InputProps<T, string[]> & { showBulletPoints?: boolean }) => {
  const html = getHTMLFromBulletListStrings(bulletListStrings);
  return (
    <InputGroupWrapper label={label} className={wrapperClassName}>
      <ContentEditable
        contentEditable={true}
        className={`${INPUT_CLASS_NAME} cursor-text [&>div]:list-item ${
          showBulletPoints ? "pl-7" : "[&>div]:list-['']"
        }`}
        // Note: placeholder currently doesn't work
        placeholder={placeholder}
        onChange={(e) => {
          if (e.type === "input") {
            const { innerText } = e.currentTarget as HTMLDivElement;
            const newBulletListStrings =
              getBulletListStringsFromInnerText(innerText);
            onChange(name, newBulletListStrings);
          }
        }}
        html={html}
      />
    </InputGroupWrapper>
  );
};

const NORMALIZED_LINE_BREAK = "\n";
/**
 * Normalize line breaks to be \n since different OS uses different line break
 *    Windows -> \r\n (CRLF)
 *    Unix    -> \n (LF)
 *    Mac     -> \n (LF), or \r (CR) for earlier versions
 */
const normalizeLineBreak = (str: string) =>
  str.replace(/\r?\n/g, NORMALIZED_LINE_BREAK);
const dedupeLineBreak = (str: string) =>
  str.replace(/\n\n/g, NORMALIZED_LINE_BREAK);
const getStringsByLineBreak = (str: string) => str.split(NORMALIZED_LINE_BREAK);

const getBulletListStringsFromInnerText = (innerText: string) => {
  const innerTextWithNormalizedLineBreak = normalizeLineBreak(innerText);

  // In Windows Chrome, pressing enter creates 2 line breaks "\n\n"
  // This dedupes it into 1 line break "\n"
  let newInnerText = dedupeLineBreak(innerTextWithNormalizedLineBreak);

  // Handle the special case when content is empty
  if (newInnerText === NORMALIZED_LINE_BREAK) {
    newInnerText = "";
  }

  return getStringsByLineBreak(newInnerText);
};

const getHTMLFromBulletListStrings = (bulletListStrings: string[]) => {
  // If bulletListStrings is an empty array, make it an empty div
  if (bulletListStrings.length === 0) {
    return "<div></div>";
  }

  return bulletListStrings.map((text) => `<div>${text}</div>`).join("");
};

/**
 * BulletListTextareaFallback is a fallback for BulletListTextareaGeneral to work around
 * content editable div issue in some browsers. For example, in Firefox, if user enters
 * space in the content editable div at the end of line, Firefox returns it as a new
 * line character \n instead of space in innerText.
 */
const BulletListTextareaFallback = <T extends string>({
  label,
  labelClassName,
  name,
  value: bulletListStrings = [],
  placeholder,
  onChange,
  showBulletPoints = true,
}: InputProps<T, string[]> & { showBulletPoints?: boolean }) => {
  const textareaValue = getTextareaValueFromBulletListStrings(
    bulletListStrings,
    showBulletPoints
  );

  return (
    <Textarea
      label={label}
      labelClassName={labelClassName}
      name={name}
      value={textareaValue}
      placeholder={placeholder}
      onChange={(name, value) => {
        onChange(
          name,
          getBulletListStringsFromTextareaValue(value, showBulletPoints)
        );
      }}
    />
  );
};

const getTextareaValueFromBulletListStrings = (
  bulletListStrings: string[],
  showBulletPoints: boolean
) => {
  const prefix = showBulletPoints ? "• " : "";

  if (bulletListStrings.length === 0) {
    return prefix;
  }

  let value = "";
  for (let i = 0; i < bulletListStrings.length; i++) {
    const string = bulletListStrings[i];
    const isLastItem = i === bulletListStrings.length - 1;
    value += `${prefix}${string}${isLastItem ? "" : "\r\n"}`;
  }
  return value;
};

const getBulletListStringsFromTextareaValue = (
  textareaValue: string,
  showBulletPoints: boolean
) => {
  const textareaValueWithNormalizedLineBreak =
    normalizeLineBreak(textareaValue);

  const strings = getStringsByLineBreak(textareaValueWithNormalizedLineBreak);

  if (showBulletPoints) {
    // Filter out empty strings
    const nonEmptyStrings = strings.filter((s) => s !== "•");

    let newStrings: string[] = [];
    for (let string of nonEmptyStrings) {
      if (string.startsWith("• ")) {
        newStrings.push(string.slice(2));
      } else if (string.startsWith("•")) {
        // Handle the special case when user wants to delete the bullet point, in which case
        // we combine it with the previous line if previous line exists
        const lastItemIdx = newStrings.length - 1;
        if (lastItemIdx >= 0) {
          const lastItem = newStrings[lastItemIdx];
          newStrings[lastItemIdx] = `${lastItem}${string.slice(1)}`;
        } else {
          newStrings.push(string.slice(1));
        }
      } else {
        newStrings.push(string);
      }
    }
    return newStrings;
  }

  return strings;
};


================================================
FILE: src/app/components/ResumeForm/Form/index.tsx
================================================
import { ExpanderWithHeightTransition } from "components/ExpanderWithHeightTransition";
import {
  DeleteIconButton,
  MoveIconButton,
  ShowIconButton,
} from "components/ResumeForm/Form/IconButton";
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
import {
  changeFormHeading,
  changeFormOrder,
  changeShowForm,
  selectHeadingByForm,
  selectIsFirstForm,
  selectIsLastForm,
  selectShowByForm,
  ShowForm,
} from "lib/redux/settingsSlice";
import {
  BuildingOfficeIcon,
  AcademicCapIcon,
  LightBulbIcon,
  WrenchIcon,
  PlusSmallIcon,
} from "@heroicons/react/24/outline";
import {
  addSectionInForm,
  deleteSectionInFormByIdx,
  moveSectionInForm,
} from "lib/redux/resumeSlice";

/**
 * BaseForm is the bare bone form, i.e. just the outline with no title and no control buttons.
 * ProfileForm uses this to compose its outline.
 */
export const BaseForm = ({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) => (
  <section
    className={`flex flex-col gap-3 rounded-md bg-white p-6 pt-4 shadow transition-opacity duration-200 ${className}`}
  >
    {children}
  </section>
);

const FORM_TO_ICON: { [section in ShowForm]: typeof BuildingOfficeIcon } = {
  workExperiences: BuildingOfficeIcon,
  educations: AcademicCapIcon,
  projects: LightBulbIcon,
  skills: WrenchIcon,
  custom: WrenchIcon,
};

export const Form = ({
  form,
  addButtonText,
  children,
}: {
  form: ShowForm;
  addButtonText?: string;
  children: React.ReactNode;
}) => {
  const showForm = useAppSelector(selectShowByForm(form));
  const heading = useAppSelector(selectHeadingByForm(form));

  const dispatch = useAppDispatch();
  const setShowForm = (showForm: boolean) => {
    dispatch(changeShowForm({ field: form, value: showForm }));
  };
  const setHeading = (heading: string) => {
    dispatch(changeFormHeading({ field: form, value: heading }));
  };

  const isFirstForm = useAppSelector(selectIsFirstForm(form));
  const isLastForm = useAppSelector(selectIsLastForm(form));

  const handleMoveClick = (type: "up" | "down") => {
    dispatch(changeFormOrder({ form, type }));
  };

  const Icon = FORM_TO_ICON[form];

  return (
    <BaseForm
      className={`transition-opacity duration-200 ${
        showForm ? "pb-6" : "pb-2 opacity-60"
      }`}
    >
      <div className="flex items-center justify-between gap-4">
        <div className="flex grow items-center gap-2">
          <Icon className="h-6 w-6 text-gray-600" aria-hidden="true" />
          <input
            type="text"
            className="block w-full border-b border-transparent text-lg font-semibold tracking-wide text-gray-900 outline-none hover:border-gray-300 hover:shadow-sm focus:border-gray-300 focus:shadow-sm"
            value={heading}
            onChange={(e) => setHeading(e.target.value)}
          />
        </div>
        <div className="flex items-center gap-0.5">
          {!isFirstForm && (
            <MoveIconButton type="up" onClick={handleMoveClick} />
          )}
          {!isLastForm && (
            <MoveIconButton type="down" onClick={handleMoveClick} />
          )}
          <ShowIconButton show={showForm} setShow={setShowForm} />
        </div>
      </div>
      <ExpanderWithHeightTransition expanded={showForm}>
        {children}
      </ExpanderWithHeightTransition>
      {showForm && addButtonText && (
        <div className="mt-2 flex justify-end">
          <button
            type="button"
            onClick={() => {
              dispatch(addSectionInForm({ form }));
            }}
            className="flex items-center rounded-md bg-white py-2 pl-3 pr-4 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
          >
            <PlusSmallIcon
              className="-ml-0.5 mr-1.5 h-5 w-5 text-gray-400"
              aria-hidden="true"
            />
            {addButtonText}
          </button>
        </div>
      )}
    </BaseForm>
  );
};

export const FormSection = ({
  form,
  idx,
  showMoveUp,
  showMoveDown,
  showDelete,
  deleteButtonTooltipText,
  children,
}: {
  form: ShowForm;
  idx: number;
  showMoveUp: boolean;
  showMoveDown: boolean;
  showDelete: boolean;
  deleteButtonTooltipText: string;
  children: React.ReactNode;
}) => {
  const dispatch = useAppDispatch();
  const handleDeleteClick = () => {
    dispatch(deleteSectionInFormByIdx({ form, idx }));
  };
  const handleMoveClick = (direction: "up" | "down") => {
    dispatch(moveSectionInForm({ form, direction, idx }));
  };

  return (
    <>
      {idx !== 0 && (
        <div className="mb-4 mt-6 border-t-2 border-dotted border-gray-200" />
      )}
      <div className="relative grid grid-cols-6 gap-3">
        {children}
        <div className={`absolute right-0 top-0 flex gap-0.5 `}>
          <div
            className={`transition-all duration-300 ${
              showMoveUp ? "" : "invisible opacity-0"
            } ${showMoveDown ? "" : "-mr-6"}`}
          >
            <MoveIconButton
              type="up"
              size="small"
              onClick={() => handleMoveClick("up")}
            />
          </div>
          <div
            className={`transition-all duration-300 ${
              showMoveDown ? "" : "invisible opacity-0"
            }`}
          >
            <MoveIconButton
              type="down"
              size="small"
              onClick={() => handleMoveClick("down")}
            />
          </div>
          <div
            className={`transition-all duration-300 ${
              showDelete ? "" : "invisible opacity-0"
            }`}
          >
            <DeleteIconButton
              onClick={handleDeleteClick}
              tooltipText={deleteButtonTooltipText}
            />
          </div>
        </div>
      </div>
    </>
  );
};


================================================
FILE: src/app/components/ResumeForm/ProfileForm.tsx
================================================
import { BaseForm } from "components/ResumeForm/Form";
import { Input, Textarea } from "components/ResumeForm/Form/InputGroup";
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
import { changeProfile, selectProfile } from "lib/redux/resumeSlice";
import { ResumeProfile } from "lib/redux/types";

export const ProfileForm = () => {
  const profile = useAppSelector(selectProfile);
  const dispatch = useAppDispatch();
  const { name, email, phone, url, summary, location } = profile;

  const handleProfileChange = (field: keyof ResumeProfile, value: string) => {
    dispatch(changeProfile({ field, value }));
  };

  return (
    <BaseForm>
      <div className="grid grid-cols-6 gap-3">
        <Input
          label="Name"
          labelClassName="col-span-full"
          name="name"
          placeholder="Sal Khan"
          value={name}
          onChange={handleProfileChange}
        />
        <Textarea
          label="Objective"
          labelClassName="col-span-full"
          name="summary"
          placeholder="Entrepreneur and educator obsessed with making education free for anyone"
          value={summary}
          onChange={handleProfileChange}
        />
        <Input
          label="Email"
          labelClassName="col-span-4"
          name="email"
          placeholder="hello@khanacademy.org"
          value={email}
          onChange={handleProfileChange}
        />
        <Input
          label="Phone"
          labelClassName="col-span-2"
          name="phone"
          placeholder="(123)456-7890"
          value={phone}
          onChange={handleProfileChange}
        />
        <Input
          label="Website"
          labelClassName="col-span-4"
          name="url"
          placeholder="linkedin.com/in/khanacademy"
          value={url}
          onChange={handleProfileChange}
        />
        <Input
          label="Location"
          labelClassName="col-span-2"
          name="location"
          placeholder="NYC, NY"
          value={location}
          onChange={handleProfileChange}
        />
      </div>
    </BaseForm>
  );
};


================================================
FILE: src/app/components/ResumeForm/ProjectsForm.tsx
================================================
import { Form, FormSection } from "components/ResumeForm/Form";
import {
  Input,
  BulletListTextarea,
} from "components/ResumeForm/Form/InputGroup";
import type { CreateHandleChangeArgsWithDescriptions } from "components/ResumeForm/types";
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
import { selectProjects, changeProjects } from "lib/redux/resumeSlice";
import type { ResumeProject } from "lib/redux/types";

export const ProjectsForm = () => {
  const projects = useAppSelector(selectProjects);
  const dispatch = useAppDispatch();
  const showDelete = projects.length > 1;

  return (
    <Form form="projects" addButtonText="Add Project">
      {projects.map(({ project, date, descriptions }, idx) => {
        const handleProjectChange = (
          ...[
            field,
            value,
          ]: CreateHandleChangeArgsWithDescriptions<ResumeProject>
        ) => {
          dispatch(changeProjects({ idx, field, value } as any));
        };
        const showMoveUp = idx !== 0;
        const showMoveDown = idx !== projects.length - 1;

        return (
          <FormSection
            key={idx}
            form="projects"
            idx={idx}
            showMoveUp={showMoveUp}
            showMoveDown={showMoveDown}
            showDelete={showDelete}
            deleteButtonTooltipText={"Delete project"}
          >
            <Input
              name="project"
              label="Project Name"
              placeholder="OpenResume"
              value={project}
              onChange={handleProjectChange}
              labelClassName="col-span-4"
            />
            <Input
              name="date"
              label="Date"
              placeholder="Winter 2022"
              value={date}
              onChange={handleProjectChange}
              labelClassName="col-span-2"
            />
            <BulletListTextarea
              name="descriptions"
              label="Description"
              placeholder="Bullet points"
              value={descriptions}
              onChange={handleProjectChange}
              labelClassName="col-span-full"
            />
          </FormSection>
        );
      })}
    </Form>
  );
};


================================================
FILE: src/app/components/ResumeForm/SkillsForm.tsx
================================================
import { Form } from "components/ResumeForm/Form";
import {
  BulletListTextarea,
  InputGroupWrapper,
} from "components/ResumeForm/Form/InputGroup";
import { FeaturedSkillInput } from "components/ResumeForm/Form/FeaturedSkillInput";
import { BulletListIconButton } from "components/ResumeForm/Form/IconButton";
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
import { selectSkills, changeSkills } from "lib/redux/resumeSlice";
import {
  selectShowBulletPoints,
  changeShowBulletPoints,
  selectThemeColor,
} from "lib/redux/settingsSlice";

export const SkillsForm = () => {
  const skills = useAppSelector(selectSkills);
  const dispatch = useAppDispatch();
  const { featuredSkills, descriptions } = skills;
  const form = "skills";
  const showBulletPoints = useAppSelector(selectShowBulletPoints(form));
  const themeColor = useAppSelector(selectThemeColor) || "#38bdf8";

  const handleSkillsChange = (field: "descriptions", value: string[]) => {
    dispatch(changeSkills({ field, value }));
  };
  const handleFeaturedSkillsChange = (
    idx: number,
    skill: string,
    rating: number
  ) => {
    dispatch(changeSkills({ field: "featuredSkills", idx, skill, rating }));
  };
  const handleShowBulletPoints = (value: boolean) => {
    dispatch(changeShowBulletPoints({ field: form, value }));
  };

  return (
    <Form form={form}>
      <div className="col-span-full grid grid-cols-6 gap-3">
        <div className="relative col-span-full">
          <BulletListTextarea
            label="Skills List"
            labelClassName="col-span-full"
            name="descriptions"
            placeholder="Bullet points"
            value={descriptions}
            onChange={handleSkillsChange}
            showBulletPoints={showBulletPoints}
          />
          <div className="absolute left-[4.5rem] top-[0.07rem]">
            <BulletListIconButton
              showBulletPoints={showBulletPoints}
              onClick={handleShowBulletPoints}
            />
          </div>
        </div>
        <div className="col-span-full mb-4 mt-6 border-t-2 border-dotted border-gray-200" />
        <InputGroupWrapper
          label="Featured Skills (Optional)"
          className="col-span-full"
        >
          <p className="mt-2 text-sm font-normal text-gray-600">
            Featured skills is optional to highlight top skills, with more
            circles mean higher proficiency.
          </p>
        </InputGroupWrapper>

        {featuredSkills.map(({ skill, rating }, idx) => (
          <FeaturedSkillInput
            key={idx}
            className="col-span-3"
            skill={skill}
            rating={rating}
            setSkillRating={(newSkill, newRating) => {
              handleFeaturedSkillsChange(idx, newSkill, newRating);
            }}
            placeholder={`Featured Skill ${idx + 1}`}
            circleColor={themeColor}
          />
        ))}
      </div>
    </Form>
  );
};


================================================
FILE: src/app/components/ResumeForm/ThemeForm/InlineInput.tsx
================================================
interface InputProps<K extends string, V extends string> {
  label: string;
  labelClassName?: string;
  name: K;
  value?: V;
  placeholder: string;
  inputStyle?: React.CSSProperties;
  onChange: (name: K, value: V) => void;
}

export const InlineInput = <K extends string>({
  label,
  labelClassName,
  name,
  value = "",
  placeholder,
  inputStyle = {},
  onChange,
}: InputProps<K, string>) => {
  return (
    <label
      className={`flex gap-2 text-base font-medium text-gray-700 ${labelClassName}`}
    >
      <span className="w-28">{label}</span>
      <input
        type="text"
        name={name}
        value={value}
        placeholder={placeholder}
        onChange={(e) => onChange(name, e.target.value)}
        className="w-[5rem] border-b border-gray-300 text-center font-semibold leading-3 outline-none"
        style={inputStyle}
      />
    </label>
  );
};


================================================
FILE: src/app/components/ResumeForm/ThemeForm/Selection.tsx
================================================
import type { GeneralSetting } from "lib/redux/settingsSlice";
import { PX_PER_PT } from "lib/constants";
import {
  FONT_FAMILY_TO_STANDARD_SIZE_IN_PT,
  FONT_FAMILY_TO_DISPLAY_NAME,
  type FontFamily,
} from "components/fonts/constants";
import { getAllFontFamiliesToLoad } from "components/fonts/lib";
import dynamic from "next/dynamic";

const Selection = ({
  selectedColor,
  isSelected,
  style = {},
  onClick,
  children,
}: {
  selectedColor: string;
  isSelected: boolean;
  style?: React.CSSProperties;
  onClick: () => void;
  children: React.ReactNode;
}) => {
  const selectedStyle = {
    color: "white",
    backgroundColor: selectedColor,
    borderColor: selectedColor,
    ...style,
  };

  return (
    <div
      className="flex w-[105px] cursor-pointer items-center justify-center rounded-md border border-gray-300 py-1.5 shadow-sm hover:border-gray-400 hover:bg-gray-100"
      onClick={onClick}
      style={isSelected ? selectedStyle : style}
      onKeyDown={(e) => {
        if (["Enter", " "].includes(e.key)) onClick();
      }}
      tabIndex={0}
    >
      {children}
    </div>
  );
};

const SelectionsWrapper = ({ children }: { children: React.ReactNode }) => {
  return <div className="mt-2 flex flex-wrap gap-3">{children}</div>;
};

const FontFamilySelections = ({
  selectedFontFamily,
  themeColor,
  handleSettingsChange,
}: {
  selectedFontFamily: string;
  themeColor: string;
  handleSettingsChange: (field: GeneralSetting, value: string) => void;
}) => {
  const allFontFamilies = getAllFontFamiliesToLoad();
  return (
    <SelectionsWrapper>
      {allFontFamilies.map((fontFamily, idx) => {
        const isSelected = selectedFontFamily === fontFamily;
        const standardSizePt = FONT_FAMILY_TO_STANDARD_SIZE_IN_PT[fontFamily];
        return (
          <Selection
            key={idx}
            selectedColor={themeColor}
            isSelected={isSelected}
            style={{
              fontFamily,
              fontSize: `${standardSizePt * PX_PER_PT}px`,
            }}
            onClick={() => handleSettingsChange("fontFamily", fontFamily)}
          >
            {FONT_FAMILY_TO_DISPLAY_NAME[fontFamily]}
          </Selection>
        );
      })}
    </SelectionsWrapper>
  );
};

/**
 * Load FontFamilySelections client side since it calls getAllFontFamiliesToLoad,
 * which uses navigator object that is only available on client side
 */
export const FontFamilySelectionsCSR = dynamic(
  () => Promise.resolve(FontFamilySelections),
  {
    ssr: false,
  }
);

export const FontSizeSelections = ({
  selectedFontSize,
  fontFamily,
  themeColor,
  handleSettingsChange,
}: {
  fontFamily: FontFamily;
  themeColor: string;
  selectedFontSize: string;
  handleSettingsChange: (field: GeneralSetting, value: string) => void;
}) => {
  const standardSizePt = FONT_FAMILY_TO_STANDARD_SIZE_IN_PT[fontFamily];
  const compactSizePt = standardSizePt - 1;

  return (
    <SelectionsWrapper>
      {["Compact", "Standard", "Large"].map((type, idx) => {
        const fontSizePt = String(compactSizePt + idx);
        const isSelected = fontSizePt === selectedFontSize;
        return (
          <Selection
            key={idx}
            selectedColor={themeColor}
            isSelected={isSelected}
            style={{
              fontFamily,
              fontSize: `${Number(fontSizePt) * PX_PER_PT}px`,
            }}
            onClick={() => handleSettingsChange("fontSize", fontSizePt)}
          >
            {type}
          </Selection>
        );
      })}
    </SelectionsWrapper>
  );
};

export const DocumentSizeSelections = ({
  selectedDocumentSize,
  themeColor,
  handleSettingsChange,
}: {
  themeColor: string;
  selectedDocumentSize: string;
  handleSettingsChange: (field: GeneralSetting, value: string) => void;
}) => {
  return (
    <SelectionsWrapper>
      {["Letter", "A4"].map((type, idx) => {
        return (
          <Selection
            key={idx}
            selectedColor={themeColor}
            isSelected={type === selectedDocumentSize}
            onClick={() => handleSettingsChange("documentSize", type)}
          >
            <div className="flex flex-col items-center">
              <div>{type}</div>
              <div className="text-xs">
                {type === "Letter" ? "(US, Canada)" : "(other countries)"}
              </div>
            </div>
          </Selection>
        );
      })}
    </SelectionsWrapper>
  );
};


================================================
FILE: src/app/components/ResumeForm/ThemeForm/constants.ts
================================================
export const THEME_COLORS = [
  "#f87171", // Red-400
  "#ef4444", // Red-500
  "#fb923c", // Orange-400
  "#f97316", // Orange-500
  "#fbbf24", // Amber-400
  "#f59e0b", // Amber-500
  "#22c55e", // Green-500
  "#15803d", // Green-700
  "#38bdf8", // Sky-400
  "#0ea5e9", // Sky-500
  "#818cf8", // Indigo-400
  "#6366f1", // Indigo-500
];


================================================
FILE: src/app/components/ResumeForm/ThemeForm/index.tsx
================================================
import { BaseForm } from "components/ResumeForm/Form";
import { InputGroupWrapper } from "components/ResumeForm/Form/InputGroup";
import { THEME_COLORS } from "components/ResumeForm/ThemeForm/constants";
import { InlineInput } from "components/ResumeForm/ThemeForm/InlineInput";
import {
  DocumentSizeSelections,
  FontFamilySelectionsCSR,
  FontSizeSelections,
} from "components/ResumeForm/ThemeForm/Selection";
import {
  changeSettings,
  DEFAULT_THEME_COLOR,
  selectSettings,
  type GeneralSetting,
} from "lib/redux/settingsSlice";
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
import type { FontFamily } from "components/fonts/constants";
import { Cog6ToothIcon } from "@heroicons/react/24/outline";

export const ThemeForm = () => {
  const settings = useAppSelector(selectSettings);
  const { fontSize, fontFamily, documentSize } = settings;
  const themeColor = settings.themeColor || DEFAULT_THEME_COLOR;
  const dispatch = useAppDispatch();

  const handleSettingsChange = (field: GeneralSetting, value: string) => {
    dispatch(changeSettings({ field, value }));
  };

  return (
    <BaseForm>
      <div className="flex flex-col gap-6">
        <div className="flex items-center gap-2">
          <Cog6ToothIcon className="h-6 w-6 text-gray-600" aria-hidden="true" />
          <h1 className="text-lg font-semibold tracking-wide text-gray-900 ">
            Resume Setting
          </h1>
        </div>
        <div>
          <InlineInput
            label="Theme Color"
            name="themeColor"
            value={settings.themeColor}
            placeholder={DEFAULT_THEME_COLOR}
            onChange={handleSettingsChange}
            inputStyle={{ color: themeColor }}
          />
          <div className="mt-2 flex flex-wrap gap-2">
            {THEME_COLORS.map((color, idx) => (
              <div
                className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-md text-sm text-white"
                style={{ backgroundColor: color }}
                key={idx}
                onClick={() => handleSettingsChange("themeColor", color)}
                onKeyDown={(e) => {
                  if (["Enter", " "].includes(e.key))
                    handleSettingsChange("themeColor", color);
                }}
                tabIndex={0}
              >
                {settings.themeColor === color ? "✓" : ""}
              </div>
            ))}
          </div>
        </div>
        <div>
          <InputGroupWrapper label="Font Family" />
          <FontFamilySelectionsCSR
            selectedFontFamily={fontFamily}
            themeColor={themeColor}
            handleSettingsChange={handleSettingsChange}
          />
        </div>
        <div>
          <InlineInput
            label="Font Size (pt)"
            name="fontSize"
            value={fontSize}
            placeholder="11"
            onChange={handleSettingsChange}
          />
          <FontSizeSelections
            fontFamily={fontFamily as FontFamily}
            themeColor={themeColor}
            selectedFontSize={fontSize}
            handleSettingsChange={handleSettingsChange}
          />
        </div>
        <div>
          <InputGroupWrapper label="Document Size" />
          <DocumentSizeSelections
            themeColor={themeColor}
            selectedDocumentSize={documentSize}
            handleSettingsChange={handleSettingsChange}
          />
        </div>
      </div>
    </BaseForm>
  );
};


================================================
FILE: src/app/components/ResumeForm/WorkExperiencesForm.tsx
================================================
import { Form, FormSection } from "components/ResumeForm/Form";
import {
  Input,
  BulletListTextarea,
} from "components/ResumeForm/Form/InputGroup";
import type { CreateHandleChangeArgsWithDescriptions } from "components/ResumeForm/types";
import { useAppDispatch, useAppSelector } from "lib/redux/hooks";
import {
  changeWorkExperiences,
  selectWorkExperiences,
} from "lib/redux/resumeSlice";
import type { ResumeWorkExperience } from "lib/redux/types";

export const WorkExperiencesForm = () => {
  const workExperiences = useAppSelector(selectWorkExperiences);
  const dispatch = useAppDispatch();

  const showDelete = workExperiences.length > 1;

  return (
    <Form form="workExperiences" addButtonText="Add Job">
      {workExperiences.map(({ company, jobTitle, date, descriptions }, idx) => {
        const handleWorkExperienceChange = (
          ...[
            field,
            value,
          ]: CreateHandleChangeArgsWithDescriptions<ResumeWorkExperience>
        ) => {
          // TS doesn't support passing union type to single call signature
          // https://github.com/microsoft/TypeScript/issues/54027
          // any is used here as a workaround
          dispatch(changeWorkExperiences({ idx, field, value } as any));
        };
        const showMoveUp = idx !== 0;
        const showMoveDown = idx !== workExperiences.length - 1;

        return (
          <FormSection
            key={idx}
            form="workExperiences"
            idx={idx}
            showMoveUp={showMoveUp}
            showMoveDown={showMoveDown}
            showDelete={showDelete}
            deleteButtonTooltipText="Delete job"
          >
            <Input
              label="Company"
              labelClassName="col-span-full"
              name="company"
              placeholder="Khan Academy"
              value={company}
              onChange={handleWorkExperienceChange}
            />
            <Input
              label="Job Title"
              labelClassName="col-span-4"
              name="jobTitle"
              placeholder="Software Engineer"
              value={jobTitle}
              onChange={handleWorkExperienceChange}
            />
            <Input
              label="Date"
              labelClassName="col-span-2"
              name="date"
              placeholder="Jun 2022 - Present"
              value={date}
              onChange={handleWorkExperienceChange}
            />
            <BulletListTextarea
              label="Description"
              labelClassName="col-span-full"
              name="descriptions"
              placeholder="Bullet points"
              value={descriptions}
              onChange={handleWorkExperienceChange}
            />
          </FormSection>
        );
      })}
    </Form>
  );
};


================================================
FILE: src/app/components/ResumeForm/index.tsx
================================================
"use client";
import { useState } from "react";
import {
  useAppSelector,
  useSaveStateToLocalStorageOnChange,
  useSetInitialStore,
} from "lib/redux/hooks";
import { ShowForm, selectFormsOrder } from "lib/redux/settingsSlice";
import { ProfileForm } from "components/ResumeForm/ProfileForm";
import { WorkExperiencesForm } from "components/ResumeForm/WorkExperiencesForm";
import { EducationsForm } from "components/ResumeForm/EducationsForm";
import { ProjectsForm } from "components/ResumeForm/ProjectsForm";
import { SkillsForm } from "components/ResumeForm/SkillsForm";
import { ThemeForm } from "components/ResumeForm/ThemeForm";
import { CustomForm } from "components/ResumeForm/CustomForm";
import { FlexboxSpacer } from "components/FlexboxSpacer";
import { cx } from "lib/cx";

const formTypeToComponent: { [type in ShowForm]: () => JSX.Element } = {
  workExperiences: WorkExperiencesForm,
  educations: EducationsForm,
  projects: ProjectsForm,
  skills: SkillsForm,
  custom: CustomForm,
};

export const ResumeForm = () => {
  useSetInitialStore();
  useSaveStateToLocalStorageOnChange();

  const formsOrder = useAppSelector(selectFormsOrder);
  const [isHover, setIsHover] = useState(false);

  return (
    <div
      className={cx(
        "flex justify-center scrollbar-thin scrollbar-track-gray-100 md:h-[calc(100vh-var(--top-nav-bar-height))] md:justify-end md:overflow-y-scroll",
        isHover ? "scrollbar-thumb-gray-200" : "scrollbar-thumb-gray-100"
      )}
      onMouseOver={() => setIsHover(true)}
      onMouseLeave={() => setIsHover(false)}
    >
      <section className="flex max-w-2xl flex-col gap-8 p-[var(--resume-padding)]">
        <ProfileForm />
        {formsOrder.map((form) => {
          const Component = formTypeToComponent[form];
          return <Component key={form} />;
        })}
        <ThemeForm />
        <br />
      </section>
      <FlexboxSpacer maxWidth={50} className="hidden md:block" />
    </div>
  );
};


================================================
FILE: src/app/components/ResumeForm/types.ts
================================================
export type CreateHandleChangeArgsWithDescriptions<T> =
  | [field: Exclude<keyof T, "descriptions">, value: string]
  | [field: "descriptions", value: string[]];


================================================
FILE: src/app/components/Tooltip.tsx
================================================
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";

/**
 * A simple Tooltip component that shows tooltip text center below children on hover and on focus
 *
 * @example
 * <Tooltip text="Tooltip Text">
 *   <div>Hello</div>
 * </Tooltip>
 */
export const Tooltip = ({
  text,
  children,
}: {
  text: string;
  children: React.ReactNode;
}) => {
  const spanRef = useRef<HTMLSpanElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);

  const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });

  const [show, setShow] = useState(false);
  const showTooltip = () => setShow(true);
  const hideTooltip = () => setShow(false);

  // Hook to set tooltip position to be right below children and centered
  useEffect(() => {
    const span = spanRef.current;
    const tooltip = tooltipRef.current;
    if (span && tooltip) {
      const rect = span.getBoundingClientRect();
      const TOP_OFFSET = 6;
      const newTop = rect.top + rect.height + TOP_OFFSET;
      const newLeft = rect.left - tooltip.offsetWidth / 2 + rect.width / 2;
      setTooltipPos({
        top: newTop,
        left: newLeft,
      });
    }
  }, [show]);

  return (
    <span
      ref={spanRef}
      onMouseEnter={showTooltip}
      onMouseLeave={hideTooltip}
      onFocus={showTooltip}
      onBlur={hideTooltip}
      // hide tooltip onClick to handle the edge case where the element position is changed after lick
      onClick={hideTooltip}
    >
      {children}
      {show &&
        createPortal(
          <div
            ref={tooltipRef}
            role="tooltip"
            className="absolute left-0 top-0 z-10 w-max rounded-md bg-gray-600 px-2 py-0.5 text-sm text-white"
            style={{
              left: `${tooltipPos.left}px`,
              top: `${tooltipPos.top}px`,
            }}
          >
            {text}
          </div>,
          document.body
        )}
    </span>
  );
};


================================================
FILE: src/app/components/TopNavBar.tsx
================================================
"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import logoSrc from "public/logo.svg";
import { cx } from "lib/cx";

export const TopNavBar = () => {
  const pathName = usePathname();
  const isHomePage = pathName === "/";

  return (
    <header
      aria-label="Site Header"
      className={cx(
        "flex h-[var(--top-nav-bar-height)] items-center border-b-2 border-gray-100 px-3 lg:px-12",
        isHomePage && "bg-dot"
      )}
    >
      <div className="flex h-10 w-full items-center justify-between">
        <Link href="/">
          <span className="sr-only">OpenResume</span>
          <Image
            src={logoSrc}
            alt="OpenResume Logo"
            className="h-8 w-full"
            priority
          />
        </Link>
        <nav
          aria-label="Site Nav Bar"
          className="flex items-center gap-2 text-sm font-medium"
        >
          {[
            ["/resume-builder", "Builder"],
            ["/resume-parser", "Parser"],
          ].map(([href, text]) => (
            <Link
              key={text}
              className="rounded-md px-1.5 py-2 text-gray-500 hover:bg-gray-100 focus-visible:bg-gray-100 lg:px-4"
              href={href}
            >
              {text}
            </Link>
          ))}
          <div className="ml-1 mt-1">
            <iframe
              src="https://ghbtns.com/github-btn.html?user=xitanggg&repo=open-resume&type=star&count=true"
              width="100"
              height="20"
              className="overflow-hidden border-none"
              title="GitHub"
            />
          </div>
        </nav>
      </div>
    </header>
  );
};


================================================
FILE: src/app/components/documentation/Badge.tsx
================================================
export const Badge = ({ children }: { children: React.ReactNode }) => (
  <span className="inline-flex rounded-md bg-blue-50 px-2 pb-0.5 align-text-bottom text-xs font-semibold text-blue-700 ring-1 ring-inset ring-blue-700/10">
    {children}
  </span>
);


================================================
FILE: src/app/components/documentation/Heading.tsx
================================================
import { cx } from "lib/cx";

const HEADING_CLASSNAMES = {
  1: "text-2xl font-bold",
  2: "text-xl font-bold",
  3: "text-lg font-semibold",
};

export const Heading = ({
  level = 1,
  children,
  className = "",
}: {
  level?: 1 | 2 | 3;
  smallMarginTop?: boolean;
  children: React.ReactNode;
  className?: string;
}) => {
  const Component = `h${level}` as const;
  return (
    <Component
      className={cx(
        "mt-[2em] text-gray-900",
        HEADING_CLASSNAMES[level],
        className
      )}
    >
      {children}
    </Component>
  );
};


================================================
FILE: src/app/components/documentation/Link.tsx
================================================
import { cx } from "lib/cx";

export const Link = ({
  href,
  children,
  className = "",
}: {
  href: string;
  children: React.ReactNode;
  className?: string;
}) => {
  return (
    <a
      href={href}
      target="_blank"
      className={cx(
        "underline underline-offset-2 hover:decoration-2",
        className
      )}
    >
      {children}
    </a>
  );
};


================================================
FILE: src/app/components/documentation/Paragraph.tsx
================================================
import { cx } from "lib/cx";

export const Paragraph = ({
  smallMarginTop = false,
  children,
  className = "",
}: {
  smallMarginTop?: boolean;
  children: React.ReactNode;
  className?: string;
}) => {
  return (
    <p
      className={cx(
        smallMarginTop ? "mt-[0.8em]" : "mt-[1.5em]",
        "text-lg text-gray-700",
        className
      )}
    >
      {children}
    </p>
  );
};


================================================
FILE: src/app/components/documentation/Table.tsx
================================================
import { cx } from "lib/cx";

export const Table = ({
  table,
  title,
  className,
  trClassNames = [],
  tdClassNames = [],
}: {
  table: React.ReactNode[][];
  title?: string;
  className?: string;
  trClassNames?: string[];
  tdClassNames?: string[];
}) => {
  const tableHeader = table[0];
  const tableBody = table.slice(1);
  return (
    <table
      className={cx("w-full divide-y border text-sm text-gray-900", className)}
    >
      <thead className="divide-y bg-gray-50 text-left align-top">
        {title && (
          <tr className="divide-x bg-gray-50">
            <th
              className="px-2 py-1.5 font-bold"
              scope="colSpan"
              colSpan={tableHeader.length}
            >
              {title}
            </th>
          </tr>
        )}
        <tr className="divide-x bg-gray-50">
          {tableHeader.map((item, idx) => (
            <th className="px-2 py-1.5 font-semibold" scope="col" key={idx}>
              {item}
            </th>
          ))}
        </tr>
      </thead>
      <tbody className="divide-y text-left align-top">
        {tableBody.map((row, rowIdx) => (
          <tr className={cx("divide-x", trClassNames[rowIdx])} key={rowIdx}>
            {row.map((item, colIdx) => (
              <td
                className={cx("px-2 py-1.5", tdClassNames[colIdx])}
                key={colIdx}
              >
                {item}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};


================================================
FILE: src/app/components/documentation/index.tsx
================================================
import { Heading } from "components/documentation/Heading";
import { Paragraph } from "components/documentation/Paragraph";
import { Link } from "components/documentation/Link";
import { Badge } from "components/documentation/Badge";
import { Table } from "components/documentation/Table";

export { Heading, Paragraph, Link, Badge, Table };


================================================
FILE: src/app/components/fonts/FontsZh.tsx
================================================
import "public/fonts/fonts-zh.css";

/**
 * Empty component. Main purpose is to load fonts-zh.css
 */
const FontsZh = () => <></>;
export default FontsZh;


================================================
FILE: src/app/components/fonts/NonEnglishFontsCSSLoader.tsx
================================================
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
import { getAllFontFamiliesToLoad } from "components/fonts/lib";

const FontsZhCSR = dynamic(() => import("components/fonts/FontsZh"), {
  ssr: false,
});

/**
 * Empty component to lazy load non-english fonts CSS conditionally
 *
 * Reference: https://prawira.medium.com/react-conditional-import-conditional-css-import-110cc58e0da6
 */
export const NonEnglishFontsCSSLazyLoader = () => {
  const [shouldLoadFontsZh, setShouldLoadFontsZh] = useState(false);

  useEffect(() => {
    if (getAllFontFamiliesToLoad().includes("NotoSansSC")) {
      setShouldLoadFontsZh(true);
    }
  }, []);

  return <>{shouldLoadFontsZh && <FontsZhCSR />}</>;
};


================================================
FILE: src/app/components/fonts/constants.ts
================================================
/**
 * Adding a new font family involves 4 steps:
 * Step 1. Add it to one of the below FONT_FAMILIES variable array:
 *         English fonts -> SANS_SERIF_ENGLISH_FONT_FAMILIES or SERIF_ENGLISH_FONT_FAMILIES
 *         Non-English fonts -> NON_ENGLISH_FONT_FAMILIES
 *         Once the font is added, it would take care of
 *         a. Registering font family for React PDF at "components/fonts/hooks.tsx"
 *         b. Loading font family for React PDF iframe at "components/Resume/ResumeIFrame.tsx"
 *         c. Adding font family selection to Resume Settings at "components/ResumeForm/ThemeForm/Selection.tsx"
 * Step 2. To load css correctly for the Resume Form:
 *         English fonts -> add it to the "public\fonts\fonts.css" file
 *         Non-English fonts -> create/update "public\fonts\fonts-<language>.css" and update "components/fonts/NonEnglishFontsCSSLazyLoader.tsx"
 * Step 3. Update FONT_FAMILY_TO_STANDARD_SIZE_IN_PT and FONT_FAMILY_TO_DISPLAY_NAME accordingly
 * Step 4. Update "public/fonts/OFL.txt" to include the new font family and credit the font creator
 *
 * IMPORTANT NOTE:
 * One major problem with adding a new font family is that most font family doesn't work with
 * React PDF out of box. The texts would appear fine in the PDF, but copying and pasting them
 * would result in different texts. See issues: https://github.com/diegomura/react-pdf/issues/915
 * and https://github.com/diegomura/react-pdf/issues/629
 *
 * A solution to this problem is to import and re-export the font with a font editor, e.g. fontforge or birdfont.
 *
 * If using fontforge, the following command can be used to export the font:
 * ./fontforge -lang=ff -c 'Open($1); Generate($2); Close();' old_font.ttf new_font.ttf
 * Note that fontforge doesn't work on non-english fonts: https://github.com/fontforge/fontforge/issues/1534
 * Also, some fonts might still not work after re-export.
 */

const SANS_SERIF_ENGLISH_FONT_FAMILIES = [
  "Roboto",
  "Lato",
  "Montserrat",
  "OpenSans",
  "Raleway",
] as const;

const SERIF_ENGLISH_FONT_FAMILIES = [
  "Caladea",
  "Lora",
  "RobotoSlab",
  "PlayfairDisplay",
  "Merriweather",
] as const;

export const ENGLISH_FONT_FAMILIES = [
  ...SANS_SERIF_ENGLISH_FONT_FAMILIES,
  ...SERIF_ENGLISH_FONT_FAMILIES,
];
type EnglishFontFamily = (typeof ENGLISH_FONT_FAMILIES)[number];

export const NON_ENGLISH_FONT_FAMILIES = ["NotoSansSC"] as const;
type NonEnglishFontFamily = (typeof NON_ENGLISH_FONT_FAMILIES)[number];

export const NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE: Record<
  NonEnglishFontFamily,
  string[]
> = {
  NotoSansSC: ["zh", "zh-CN", "zh-TW"],
};

export type FontFamily = EnglishFontFamily | NonEnglishFontFamily;
export const FONT_FAMILY_TO_STANDARD_SIZE_IN_PT: Record<FontFamily, number> = {
  // Sans Serif Fonts
  Roboto: 11,
  Lato: 11,
  Montserrat: 10,
  OpenSans: 10,
  Raleway: 10,
  // Serif Fonts
  Caladea: 11,
  Lora: 11,
  RobotoSlab: 10,
  PlayfairDisplay: 10,
  Merriweather: 10,
  // Non-English Fonts
  NotoSansSC: 11,
};

export const FONT_FAMILY_TO_DISPLAY_NAME: Record<FontFamily, string> = {
  // Sans Serif Fonts
  Roboto: "Roboto",
  Lato: "Lato",
  Montserrat: "Montserrat",
  OpenSans: "Open Sans",
  Raleway: "Raleway",
  // Serif Fonts
  Caladea: "Caladea",
  Lora: "Lora",
  RobotoSlab: "Roboto Slab",
  PlayfairDisplay: "Playfair Display",
  Merriweather: "Merriweather",
  // Non-English Fonts
  NotoSansSC: "思源黑体(简体)",
};


================================================
FILE: src/app/components/fonts/hooks.tsx
================================================
import { useEffect } from "react";
import { Font } from "@react-pdf/renderer";
import { ENGLISH_FONT_FAMILIES } from "components/fonts/constants";
import { getAllFontFamiliesToLoad } from "components/fonts/lib";

/**
 * Register all fonts to React PDF so it can render fonts correctly in PDF
 */
export const useRegisterReactPDFFont = () => {
  useEffect(() => {
    const allFontFamilies = getAllFontFamiliesToLoad();
    allFontFamilies.forEach((fontFamily) => {
      Font.register({
        family: fontFamily,
        fonts: [
          {
            src: `fonts/${fontFamily}-Regular.ttf`,
          },
          {
            src: `fonts/${fontFamily}-Bold.ttf`,
            fontWeight: "bold",
          },
        ],
      });
    });
  }, []);
};

export const useRegisterReactPDFHyphenationCallback = (fontFamily: string) => {
  useEffect(() => {
    if (ENGLISH_FONT_FAMILIES.includes(fontFamily as any)) {
      // Disable hyphenation for English Font Family so the word wraps each line
      // https://github.com/diegomura/react-pdf/issues/311#issuecomment-548301604
      Font.registerHyphenationCallback((word) => [word]);
    } else {
      // React PDF doesn't understand how to wrap non-english word on line break
      // A workaround is to add an empty character after each word
      // Reference https://github.com/diegomura/react-pdf/issues/1568
      Font.registerHyphenationCallback((word) =>
        word
          .split("")
          .map((char) => [char, ""])
          .flat()
      );
    }
  }, [fontFamily]);
};


================================================
FILE: src/app/components/fonts/lib.ts
================================================
"use client";
import {
  ENGLISH_FONT_FAMILIES,
  NON_ENGLISH_FONT_FAMILIES,
  NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE,
} from "components/fonts/constants";

/**
 * getPreferredNonEnglishFontFamilies returns non-english font families that are included in
 * user's preferred languages. This is to avoid loading fonts/languages that users won't use.
 */
const getPreferredNonEnglishFontFamilies = () => {
  return NON_ENGLISH_FONT_FAMILIES.filter((fontFamily) => {
    const fontLanguages = NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE[fontFamily];
    const userPreferredLanguages = navigator.languages ?? [navigator.language];
    return userPreferredLanguages.some((preferredLanguage) =>
      fontLanguages.includes(preferredLanguage)
    );
  });
};

export const getAllFontFamiliesToLoad = () => {
  return [...ENGLISH_FONT_FAMILIES, ...getPreferredNonEnglishFontFamilies()];
};


================================================
FILE: src/app/globals-css.ts
================================================
export const CSS_VARIABLES = {
  "--top-nav-bar-height": "3.5rem",
  "--resume-control-bar-height": "3rem",
  "--resume-padding": "1.5rem",
} as const;


================================================
FILE: src/app/globals.css
================================================
@import url("/fonts/fonts.css");
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .btn-primary {
    @apply bg-primary outline-theme-purple inline-block rounded-full px-6 py-2 font-semibold shadow-sm;
  }
  .text-primary {
    @apply bg-gradient-to-r from-[color:var(--theme-purple)] to-[color:var(--theme-blue)] bg-clip-text text-transparent !important;
  }
  .bg-primary {
    @apply bg-gradient-to-r from-[color:var(--theme-purple)] to-[color:var(--theme-blue)] text-white;
  }
  .outline-theme-purple {
    @apply hover:opacity-80 hover:outline-[color:var(--theme-purple)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--theme-purple)];
  }
  .outline-theme-blue {
    @apply hover:opacity-80 hover:outline-[color:var(--theme-blue)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--theme-blue)];
  }
  .within-outline-theme-purple {
    @apply focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[color:var(--theme-purple)] hover:opacity-80 hover:outline-[color:var(--theme-purple)];
  }
}

:root {
  --theme-purple: #5d52d9;
  --theme-blue: #4fc5eb;
  /* Keep the below variable names in sync with CSS_VARIABLES in globals-css.ts */
  --top-nav-bar-height: 3.5rem;
  --resume-control-bar-height: 3rem;
  --resume-padding: 1.5rem;
}


================================================
FILE: src/app/home/AutoTypingResume.tsx
================================================
"use client";
import { useEffect, useState, useRef } from "react";
import { ResumePDF } from "components/Resume/ResumePDF";
import { initialResumeState } from "lib/redux/resumeSlice";
import { initialSettings } from "lib/redux/settingsSlice";
import { ResumeIframeCSR } from "components/Resume/ResumeIFrame";
import { START_HOME_RESUME, END_HOME_RESUME } from "home/constants";
import { makeObjectCharIterator } from "lib/make-object-char-iterator";
import { useTailwindBreakpoints } from "lib/hooks/useTailwindBreakpoints";
import { deepClone } from "lib/deep-clone";

// countObjectChar(END_HOME_RESUME) -> ~1800 chars
const INTERVAL_MS = 50; // 20 Intervals Per Second
const CHARS_PER_INTERVAL = 10;
// Auto Typing Time:
//  10 CHARS_PER_INTERVAL -> ~1800 / (20*10) = 9s (let's go with 9s so it feels fast)
//  9 CHARS_PER_INTERVAL -> ~1800 / (20*9) = 10s
//  8 CHARS_PER_INTERVAL -> ~1800 / (20*8) = 11s

const RESET_INTERVAL_MS = 60 * 1000; // 60s

export const AutoTypingResume = () => {
  const [resume, setResume] = useState(deepClone(initialResumeState));
  const resumeCharIterator = useRef(
    makeObjectCharIterator(START_HOME_RESUME, END_HOME_RESUME)
  );
  const hasSetEndResume = useRef(false);
  const { isLg } = useTailwindBreakpoints();

  useEffect(() => {
    const intervalId = setInterval(() => {
      let next = resumeCharIterator.current.next();
      for (let i = 0; i < CHARS_PER_INTERVAL - 1; i++) {
        next = resumeCharIterator.current.next();
      }
      if (!next.done) {
        setResume(next.value);
      } else {
        // Sometimes the iterator doesn't end on the last char,
        // so we manually set its end state here
        if (!hasSetEndResume.current) {
          setResume(END_HOME_RESUME);
          hasSetEndResume.current = true;
        }
      }
    }, INTERVAL_MS);
    return () => clearInterval(intervalId);
  }, []);

  useEffect(() => {
    const intervalId = setInterval(() => {
      resumeCharIterator.current = makeObjectCharIterator(
        START_HOME_RESUME,
        END_HOME_RESUME
      );
      hasSetEndResume.current = false;
    }, RESET_INTERVAL_MS);
    return () => clearInterval(intervalId);
  }, []);

  return (
    <>
      <ResumeIframeCSR documentSize="Letter" scale={isLg ? 0.7 : 0.5}>
        <ResumePDF
          resume={resume}
          settings={{
            ...initialSettings,
            fontSize: "12",
            formToHeading: {
              workExperiences: resume.workExperiences[0].company
                ? "WORK EXPERIENCE"
                : "",
              educations: resume.educations[0].school ? "EDUCATION" : "",
              projects: resume.projects[0].project ? "PROJECT" : "",
              skills: resume.skills.featuredSkills[0].skill ? "SKILLS" : "",
              custom: "CUSTOM SECTION",
            },
          }}
        />
      </ResumeIframeCSR>
    </>
  );
};


================================================
FILE: src/app/home/Features.tsx
================================================
import Image from "next/image";
import featureFreeSrc from "public/assets/feature-free.svg";
import featureUSSrc from "public/assets/feature-us.svg";
import featurePrivacySrc from "public/assets/feature-privacy.svg";
import featureOpenSourceSrc from "public/assets/feature-open-source.svg";
import { Link } from "components/documentation";

const FEATURES = [
  {
    src: featureFreeSrc,
    title: "Free Forever",
    text: "OpenResume is created with the belief that everyone should have free and easy access to a modern professional resume design",
  },
  {
    src: featureUSSrc,
    title: "U.S. Best Practices",
    text: "OpenResume has built-in best practices for the U.S. job market and works well with top ATS platforms such as Greenhouse and Lever",
  },
  {
    src: featurePrivacySrc,
    title: "Privacy Focus",
    text: "OpenResume stores data locally in your browser so only you have access to your data and with complete control",
  },
  {
    src: featureOpenSourceSrc,
    title: "Open-Source",
    text: (
      <>
        OpenResume is an open-source project, and its source code can be viewed
        by anyone on its{" "}
        <Link href="https://github.com/xitanggg/open-resume">
          GitHub repository
        </Link>
      </>
    ),
  },
];

export const Features = () => {
  return (
    <section className="py-16 lg:py-36">
      <div className="mx-auto lg:max-w-4xl">
        <dl className="grid grid-cols-1 justify-items-center gap-y-8 lg:grid-cols-2 lg:gap-x-6 lg:gap-y-16">
          {FEATURES.map(({ src, title, text }) => (
            <div className="px-2" key={title}>
              <div className="relative w-96 self-center pl-16">
                <dt className="text-2xl font-bold">
                  <Image
                    src={src}
                    className="absolute left-0 top-1 h-12 w-12"
                    alt="Feature icon"
                  />
                  {title}
                </dt>
                <dd className="mt-2">{text}</dd>
              </div>
            </div>
          ))}
        </dl>
      </div>
    </section>
  );
};


================================================
FILE: src/app/home/Hero.tsx
================================================
import Link from "next/link";
import { FlexboxSpacer } from "components/FlexboxSpacer";
import { AutoTypingResume } from "home/AutoTypingResume";

export const Hero = () => {
  return (
    <section className="lg:flex lg:h-[825px] lg:justify-center">
      <FlexboxSpacer maxWidth={75} minWidth={0} className="hidden lg:block" />
      <div className="mx-auto max-w-xl pt-8 text-center lg:mx-0 lg:grow lg:pt-32 lg:text-left">
        <h1 className="text-primary pb-2 text-4xl font-bold lg:text-5xl">
          Create a professional
          <br />
          resume easily
        </h1>
        <p className="mt-3 text-lg lg:mt-5 lg:text-xl">
          With this free, open-source, and powerful resume builder
        </p>
        <Link href="/resume-import" className="btn-primary mt-6 lg:mt-14">
          Create Resume <span aria-hidden="true">→</span>
        </Link>
        <p className="ml-6 mt-3 text-sm text-gray-600">No sign up required</p>
        <p className="mt-3 text-sm text-gray-600 lg:mt-36">
          Already have a resume? Test its ATS readability with the{" "}
          <Link href="/resume-parser" className="underline underline-offset-2">
            resume parser
          </Link>
        </p>
      </div>
      <FlexboxSpacer maxWidth={100} minWidth={50} className="hidden lg:block" />
      <div className="mt-6 flex justify-center lg:mt-4 lg:block lg:grow">
        <AutoTypingResume />
      </div>
    </section>
  );
};


================================================
FILE: src/app/home/LogoCloud.tsx
================================================
import logoCornellSrc from "public/assets/logo-cornell.svg";
import logoColumbiaSrc from "public/assets/logo-columbia.svg";
import logoNortheasternSrc from "public/assets/logo-northeastern.svg";
import logoDropboxSrc from "public/assets/logo-dropbox.svg";
import logoGoogleSrc from "public/assets/logo-google.svg";
import logoAmazonSrc from "public/assets/logo-amazon.svg";
import Image from "next/image";

const LOGOS = [
  { src: logoCornellSrc, alt: "Cornell University logo" },
  { src: logoColumbiaSrc, alt: "Columbia University logo" },
  { src: logoNortheasternSrc, alt: "Northeastern University logo" },
  { src: logoDropboxSrc, alt: "Dropbox logo" },
  { src: logoGoogleSrc, alt: "Google logo" },
  { src: logoAmazonSrc, alt: "Amazon logo" },
];

// LogoCloud is disabled per issue: https://github.com/xitanggg/open-resume/issues/7
export const LogoCloud = () => (
  <section className="mt-14 lg:mt-10">
    <h2 className="text-center font-semibold text-gray-500">
      Trusted by students and employees from top universities and companies
      worldwide
    </h2>
    <div className="mt-6 grid grid-cols-6 items-center justify-items-center gap-x-8 gap-y-10">
      {LOGOS.map(({ src, alt }, idx) => (
        <Image
          key={idx}
          className="col-span-3 h-full max-h-10 max-w-[130px] lg:col-span-1 lg:max-w-[160px]"
          src={src}
          alt={alt}
        />
      ))}
    </div>
  </section>
);


================================================
FILE: src/app/home/QuestionsAndAnswers.tsx
================================================
import { Link } from "components/documentation";

const QAS = [
  {
    question:
      "Q1. What is a resume builder? Why resume builder is better than resume template doc?",
    answer: (
      <>
        <p>
          There are two ways to create a resume today. One option is to use a
          resume template, such as an office/google doc, and customize it
          according to your needs. The other option is to use a resume builder,
          an online tool that allows you to input your information and
          automatically generates a resume for you.
        </p>
        <p>
          Using a resume template requires manual formatting work, like copying
          and pasting text sections and adjusting spacing, which can be
          time-consuming and error-prone. It is easy to run into formatting
          issues, such as using different bullet points or font styles after
          copying and pasting. On the other hand, a resume builder like
          OpenResume saves time and prevents formatting mistakes by
          automatically formatting the resume. It also offers the convenience of
          easily changing font types or sizes with a simple click. In summary, a
          resume builder is easier to use compared to a resume template.
        </p>
      </>
    ),
  },
  {
    question:
      "Q2. What uniquely sets OpenResume apart from other resume builders and templates?",
    answer: (
      <>
        <p>
          Other than OpenResume, there are some great free resume builders out
          there, e.g. <Link href="https://rxresu.me/">Reactive Resume</Link>,{" "}
          <Link href="https://flowcv.com/">FlowCV</Link>. However, OpenResume
          stands out with 2 distinctive features:
        </p>{" "}
        <p>
          <span className="font-semibold">
            1. OpenResume is designed specifically for the U.S. job market and
            best practices.
          </span>
          <br />
          Unlike other resume builders that target a global audience and offer
          many customization options, OpenResume intentionally only offers
          options that are aligned with U.S. best practices. For example, it
          excludes the option to add a profile picture to avoid bias and
          discrimination. It offers only the core sections, e.g. profile, work
          experience, education, and skills, while omitting unnecessary sections
          like references. Additionally, OpenResume only offers a top down
          single column resume design as opposed to two column design, because
          single column design works best for AST. <br />{" "}
        </p>
        <p>
          <span className="font-semibold">
            2. OpenResume is super privacy focus.
          </span>{" "}
          <br />
          While other resume builders may require email sign up and store user
          data in their databases, OpenResume believes that resume data should
          remain private and accessible only on user’s local machine. Therefore,
          OpenResume doesn’t require sign up to use the app, and all inputted
          data is stored in user’s browser that only user has access to.
        </p>
      </>
    ),
  },
  {
    question: "Q3. Who created OpenResume and why?",
    answer: (
      <p>
        OpenResume was created by{" "}
        <Link href="https://github.com/xitanggg">Xitang Zhao</Link> and designed
        by <Link href="https://www.linkedin.com/in/imzhi">Zhigang Wen</Link> as
        a weekend project. As immigrants to the US, we had made many mistakes
        when creating our first resumes and applying for internships and jobs.
        It took us a long while to learn some of the best practices. While
        mentoring first generation students and reviewing their resumes, we
        noticed students were making the same mistakes that we had made before.
        This led us to think about how we can be of help with the knowledge and
        skills we have gained. We started chatting and working over the weekends
        that led to OpenResume, where we integrated best practices and our
        knowledge into this resume builder. Our hope is that OpenResume can help
        anyone to easily create a modern professional resume that follows best
        practices and enable anyone to apply for jobs with confidence.
      </p>
    ),
  },
  {
    question: "Q4. How can I support OpenResume?",
    answer: (
      <>
        <p>
          The best way to support OpenResume is to share your thoughts and
          feedback with us to help further improve it. You can send us an email
          at{" "}
          <Link href="mailto:hello@open-resume.com">hello@open-resume.com</Link>{" "}
          or{" "}
          <Link href="https://github.com/xitanggg/open-resume/issues/new">
            open an issue
          </Link>{" "}
          at our Github repository. Whether you like it or not, we would love to
          hear from you.
        </p>
        <p>
          Another great way to support OpenResume is by spreading the words.
          Share it with your friends, on social media platforms, or with your
          school’s career center. Our goal is to reach more people who struggle
          with creating their resume, and your word-of-mouth support would be
          greatly appreciated. If you use Github, you can also show your support
          by{" "}
          <Link href="https://github.com/xitanggg/open-resume">
            giving the project a star
          </Link>{" "}
          to help increase its popularity and reach.
        </p>
      </>
    ),
  },
];

export const QuestionsAndAnswers = () => {
  return (
    <section className="mx-auto max-w-3xl divide-y divide-gray-300 lg:mt-4 lg:px-2">
      <h2 className="text-center text-3xl font-bold">Questions & Answers</h2>
      <div className="mt-6 divide-y divide-gray-300">
        {QAS.map(({ question, answer }) => (
          <div key={question} className="py-6">
            <h3 className="font-semibold leading-7">{question}</h3>
            <div className="mt-3 grid gap-2 leading-7 text-gray-600">
              {answer}
            </div>
          </div>
        ))}
      </div>
    </section>
  );
};


================================================
FILE: src/app/home/Steps.tsx
================================================
const STEPS = [
  { title: "Add a resume pdf", text: "or create from scratch" },
  { title: "Preview design", text: "and make edits" },
  { title: "Download new resume", text: "and apply with confidence" },
];

export const Steps = () => {
  return (
    <section className="mx-auto mt-8 rounded-2xl bg-sky-50 bg-dot px-8 pb-12 pt-10 lg:mt-2">
      <h1 className="text-center text-3xl font-bold">3 Simple Steps</h1>
      <div className="mt-8 flex justify-center">
        <dl className="flex flex-col gap-y-10 lg:flex-row lg:justify-center lg:gap-x-20">
          {STEPS.map(({ title, text }, idx) => (
            <div className="relative self-start pl-14" key={idx}>
              <dt className="text-lg font-bold">
                <div className="bg-primary absolute left-0 top-1 flex h-10 w-10 select-none items-center justify-center rounded-full p-[3.5px] opacity-80">
                  <div className="flex h-full w-full items-center justify-center rounded-full bg-white">
                    <div className="text-primary -mt-0.5 text-2xl">
                      {idx + 1}
                    </div>
                  </div>
                </div>
                {title}
              </dt>
              <dd>{text}</dd>
            </div>
          ))}
        </dl>
      </div>
    </section>
  );
};


================================================
FILE: src/app/home/Testimonials.tsx
================================================
"use client";
import heartSrc from "public/assets/heart.svg";
import testimonialSpiegelSrc from "public/assets/testimonial-spiegel.jpg";
import testimonialSantiSrc from "public/assets/testimonial-santi.jpg";
import testimonialVivianSrc from "public/assets/testimonial-vivian.jpg";
import React, { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useTailwindBreakpoints } from "lib/hooks/useTailwindBreakpoints";

const TESTIMONIALS = [
  {
    src: testimonialSpiegelSrc,
    quote:
      "Students often make silly mistakes on their resume by using inconsistent bullet points or font sizes. OpenResume’s auto format feature is a great help to ensure consistent format.",
    name: "Ms. Spiegel",
    title: "Educator",
  },
  {
    src: testimonialSantiSrc,
    quote:
      "I used OpenResume during my last job search and was invited to interview at top tech companies such as Google and Amazon thanks to its slick yet professional resume design.",
    name: "Santi",
    title: "Software Engineer",
  },
  {
    src: testimonialVivianSrc,
    quote:
      "Creating a professional resume on OpenResume is so smooth and easy! It saves me so much time and headache to not deal with google doc template.",
    name: "Vivian",
    title: "College Student",
  },
];

const LG_TESTIMONIALS_CLASSNAMES = [
  "z-10",
  "translate-x-44 translate-y-24 opacity-40",
  "translate-x-32 -translate-y-28 opacity-40",
];
const SM_TESTIMONIALS_CLASSNAMES = ["z-10", "opacity-0", "opacity-0"];
const ROTATION_INTERVAL_MS = 8 * 1000; // 8s

export const Testimonials = ({ children }: { children?: React.ReactNode }) => {
  const [testimonialsClassNames, setTestimonialsClassNames] = useState(
    LG_TESTIMONIALS_CLASSNAMES
  );
  const isHoveredOnTestimonial = useRef(false);
  useEffect(() => {
    const intervalId = setInterval(() => {
      if (!isHoveredOnTestimonial.current) {
        setTestimonialsClassNames((preClassNames) => {
          return [preClassNames[1], preClassNames[2], preClassNames[0]];
        });
      }
    }, ROTATION_INTERVAL_MS);
    return () => clearInterval(intervalId);
  }, []);

  const { isLg } = useTailwindBreakpoints();
  useEffect(() => {
    setTestimonialsClassNames(
      isLg ? LG_TESTIMONIALS_CLASSNAMES : SM_TESTIMONIALS_CLASSNAMES
    );
  }, [isLg]);

  return (
    <section className="mx-auto -mt-2 px-8 pb-24">
      <h2 className="mb-8 text-center text-3xl font-bold">
        People{" "}
        <Image src={heartSrc} alt="love" className="-mt-1 inline-block w-7" />{" "}
        OpenResume
      </h2>
      <div className="mx-auto mt-10 h-[235px] max-w-lg lg:h-[400px] lg:pt-28">
        <div className="relative lg:ml-[-50px]">
          {TESTIMONIALS.map(({ src, quote, name, title }, idx) => {
            const className = testimonialsClassNames[idx];
            return (
              <div
                key={idx}
                className={`bg-primary absolute max-w-lg rounded-[1.7rem] bg-opacity-30 shadow-md transition-all duration-1000 ease-linear ${className}`}
                onMouseEnter={() => {
                  if (className === "z-10") {
                    isHoveredOnTestimonial.current = true;
                  }
                }}
                onMouseLeave={() => {
                  if (className === "z-10") {
                    isHoveredOnTestimonial.current = false;
                  }
                }}
              >
                <figure className="m-1 flex gap-5 rounded-3xl bg-white p-5 text-gray-900 lg:p-7">
                  <Image
                    className="hidden h-24 w-24 select-none rounded-full lg:block"
                    src={src}
                    alt="profile"
                  />
                  <div>
                    <blockquote>
                      <p className="before:content-['“'] after:content-['”']">
                        {quote}
                      </p>
                    </blockquote>
                    <figcaption className="mt-3">
                      <div className="hidden gap-2 lg:flex">
                        <div className="font-semibold">{name}</div>
                        <div
                          className="select-none text-gray-700"
                          aria-hidden="true"
                        >
                          •
                        </div>
                        <div className="text-gray-600">{title}</div>
                      </div>
                      <div className="flex gap-4 lg:hidden">
                        <Image
                          className=" block h-12 w-12 select-none rounded-full"
                          src={src}
                          alt="profile"
                        />
                        <div>
                          <div className="font-semibold">{name}</div>
                          <div className="text-gray-600">{title}</div>
                        </div>
                      </div>
                    </figcaption>
                  </div>
                </figure>
              </div>
            );
          })}
        </div>
      </div>
      {children}
    </section>
  );
};


================================================
FILE: src/app/home/constants.ts
================================================
import {
  initialEducation,
  initialProfile,
  initialProject,
  initialWorkExperience,
} from "lib/redux/resumeSlice";
import type { Resume } from "lib/redux/types";
import { deepClone } from "lib/deep-clone";

export const END_HOME_RESUME: Resume = {
  profile: {
    name: "John Doe",
    summary:
      "Software engineer obsessed with building exceptional products that people love",
    email: "hello@openresume.com",
    phone: "123-456-7890",
    location: "NYC, NY",
    url: "linkedin.com/in/john-doe",
  },
  workExperiences: [
    {
      company: "ABC Company",
      jobTitle: "Software Engineer",
      date: "May 2023 - Present",
      descriptions: [
        "Lead a cross-functional team of 5 engineers in developing a search bar, which enables thousands of daily active users to search content across the entire platform",
        "Create stunning home page product demo animations that drives up sign up rate by 20%",
        "Write clean code that is modular and easy to maintain while ensuring 100% test coverage",
      ],
    },
    {
      company: "DEF Organization",
      jobTitle: "Software Engineer Intern",
      date: "Summer 2022",
      descriptions: [
        "Re-architected the existing content editor to be mobile responsive that led to a 10% increase in mobile user engagement",
        "Created a progress bar to help users track progress that drove up user retention by 15%",
        "Discovered and fixed 5 bugs in the existing codebase to enhance user experience",
      ],
    },
    {
      company: "XYZ University",
      jobTitle: "Research Assistant",
      date: "Summer 2021",
      descriptions: [
        "Devised a new NLP algorithm in text classification that results in 10% accuracy increase",
        "Compiled and presented research findings to a group of 20+ faculty and students",
      ],
    },
  ],
  educations: [
    {
      school: "XYZ University",
      degree: "Bachelor of Science in Computer Science",
      date: "Sep 2019 - May 2023",
      gpa: "3.8",
      descriptions: [
        "Won 1st place in 2022 Education Hackathon, 2nd place in 2023 Health Tech Competition",
        "Teaching Assistant for Programming for the Web (2022 - 2023)",
        "Coursework: Object-Oriented Programming (A+), Programming for the Web (A+), Cloud Computing (A), Introduction to Machine Learning (A-), Algorithms Analysis (A-)",
      ],
    },
  ],
  projects: [
    {
      project: "OpenResume",
      date: "Spring 2023",
      descriptions: [
        "Created and launched a free resume builder web app that allows thousands of users to create professional resume easily and land their dream jobs",
      ],
    },
  ],
  skills: {
    featuredSkills: [
      { skill: "HTML", rating: 4 },
      { skill: "CSS", rating: 4 },
      { skill: "Python", rating: 3 },
      { skill: "TypeScript", rating: 3 },
      { skill: "React", rating: 3 },
      { skill: "C++", rating: 2 },
    ],
    descriptions: [
      "Tech: React Hooks, GraphQL, Node.js, SQL, Postgres, NoSql, Redis, REST API, Git",
      "Soft: Teamwork, Creative Problem Solving, Communication, Learning Mindset, Agile",
    ],
  },
  custom: {
    descriptions: [],
  },
};

export const START_HOME_RESUME: Resume = {
  profile: deepClone(initialProfile),
  workExperiences: END_HOME_RESUME.workExperiences.map(() =>
    deepClone(initialWorkExperience)
  ),
  educations: [deepClone(initialEducation)],
  projects: [deepClone(initialProject)],
  skills: {
    featuredSkills: END_HOME_RESUME.skills.featuredSkills.map((item) => ({
      skill: "",
      rating: item.rating,
    })),
    descriptions: [],
  },
  custom: {
    descriptions: [],
  },
};


================================================
FILE: src/app/layout.tsx
================================================
import "globals.css";
import { TopNavBar } from "components/TopNavBar";
import { Analytics } from "@vercel/analytics/react";

export const metadata = {
  title: "OpenResume - Free Open-source Resume Builder and Parser",
  description:
    "OpenResume is a free, open-source, and powerful resume builder that allows anyone to create a modern professional resume in 3 simple steps. For those who have an existing resume, OpenResume also provides a resume parser to help test and confirm its ATS readability.",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <TopNavBar />
        {children}
        <Analytics />
      </body>
    </html>
  );
}


================================================
FILE: src/app/lib/__tests__/cx.test.ts
================================================
import { cx } from "lib/cx";

test("cx", () => {
  expect(cx("px-1", "mt-2")).toEqual("px-1 mt-2");
  expect(cx("px-1", true && "mt-2")).toEqual("px-1 mt-2");
  expect(cx("px-1", false && "mt-2")).toEqual("px-1");
});


================================================
FILE: src/app/lib/__tests__/make-object-char-iterator.test.ts
================================================
import { makeObjectCharIterator } from "lib/make-object-char-iterator";

test("Simple object", () => {
  const start = { a: "" };
  const end = { a: "abc" };
  const iterator = makeObjectCharIterator(start, end);
  expect(iterator.next().value).toEqual({ a: "a" });
  expect(iterator.next().value).toEqual({ a: "ab" });
  expect(iterator.next().value).toEqual({ a: "abc" });
  expect(iterator.next().value).toEqual(undefined);
});

test("Nested object", () => {
  const start = { a: { b: "" } };
  const end = { a: { b: "abc" } };
  const iterator = makeObjectCharIterator(start, end);
  expect(iterator.next().value).toEqual({ a: { b: "a" } });
  expect(iterator.next().value).toEqual({ a: { b: "ab" } });
  expect(iterator.next().value).toEqual({ a: { b: "abc" } });
  expect(iterator.next().value).toEqual(undefined);
});


================================================
FILE: src/app/lib/constants.ts
================================================
export const PX_PER_PT = 4 / 3;

// Reference: https://www.prepressure.com/library/paper-size/letter
// Letter size is commonly used in US & Canada, while A4 is the standard for rest of world.
export const LETTER_WIDTH_PT = 612;
const LETTER_HEIGHT_PT = 792;
export const LETTER_WIDTH_PX = LETTER_WIDTH_PT * PX_PER_PT;
export const LETTER_HEIGHT_PX = LETTER_HEIGHT_PT * PX_PER_PT;

// Reference: https://www.prepressure.com/library/paper-size/din-a4
export const A4_WIDTH_PT = 595;
const A4_HEIGHT_PT = 842;
export const A4_WIDTH_PX = A4_WIDTH_PT * PX_PER_PT;
export const A4_HEIGHT_PX = A4_HEIGHT_PT * PX_PER_PT;

export const DEBUG_RESUME_PDF_FLAG: true | undefined = undefined; // use undefined to disable to deal with a weird error message


================================================
FILE: src/app/lib/cx.ts
================================================
/**
 * cx is a simple util to join classNames together. Think of it as a simplified version of the open source classnames util
 * Reference: https://dev.to/gugaguichard/replace-clsx-classnames-or-classcat-with-your-own-little-helper-3bf
 *
 * @example
 * cx('px-1', 'mt-2'); // => 'px-1 mt-2'
 * cx('px-1', true && 'mt-2'); // => 'px-1 mt-2'
 * cx('px-1', false && 'mt-2'); // => 'px-1'
 */
export const cx = (...classes: Array<string | boolean | undefined>) => {
  const newClasses = [];
  for (const c of classes) {
    if (typeof c === "string") {
      newClasses.push(c.trim());
    }
  }
  return newClasses.join(" ");
};


================================================
FILE: src/app/lib/deep-clone.ts
================================================
/**
 * Server side object deep clone util using JSON serialization.
 * Not efficient for large objects but good enough for most use cases.
 *
 * Client side can simply use structuredClone.
 */
export const deepClone = <T extends { [key: string]: any }>(object: T) =>
  JSON.parse(JSON.stringify(object)) as T;


================================================
FILE: src/app/lib/deep-merge.ts
================================================
type Object = { [key: string]: any };

const isObject = (item: any): item is Object => {
  return item && typeof item === "object" && !Array.isArray(item);
};

/**
 * Deep merge two objects by overriding target with fields in source.
 * It returns a new object and doesn't modify any object in place since
 * it deep clones the target object first.
 */
export const deepMerge = (target: Object, source: Object, level = 0) => {
  const copyTarget = level === 0 ? structuredClone(target) : target;
  for (const key in source) {
    const sourceValue = source[key];
    // Assign source value to copyTarget if source value is not an object.
    // Otherwise, call deepMerge recursively to merge all its keys
    if (!isObject(sourceValue)) {
      copyTarget[key] = sourceValue;
    } else {
      if (!isObject(copyTarget[key])) {
        copyTarget[key] = {};
      }
      deepMerge(copyTarget[key], sourceValue, level + 1);
    }
  }
  return copyTarget;
};


================================================
FILE: src/app/lib/get-px-per-rem.ts
================================================
export const getPxPerRem = () => {
  const bodyComputedStyle = getComputedStyle(
    document.querySelector("body")!
  ) as any;
  return parseFloat(bodyComputedStyle["font-size"]) || 16;
};


================================================
FILE: src/app/lib/hooks/useAutosizeTextareaHeight.tsx
================================================
import { useEffect, useRef } from "react";

/**
 * Hook to autosize textarea height.
 *
 * The trick to resize is to first set its height to 0 and then set it back to scroll height.
 * Reference: https://stackoverflow.com/a/25621277/7699841
 *
 * @example // Tailwind CSS
 * const textareaRef = useAutosizeTextareaHeight({ value });
 * <textarea ref={textareaRef} className="resize-none overflow-hidden"/>
 */
export const useAutosizeTextareaHeight = ({ value }: { value: string }) => {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const resizeHeight = () => {
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = "0px";
      textarea.style.height = `${textarea.scrollHeight}px`;
    }
  };

  // Resize height when value changes
  useEffect(() => {
    resizeHeight();
  }, [value]);

  // Resize height when viewport resizes
  useEffect(() => {
    window.addEventListener("resize", resizeHeight);
    return () => window.removeEventListener("resize", resizeHeight);
  }, []);

  return textareaRef;
};


================================================
FILE: src/app/lib/hooks/useTailwindBreakpoints.tsx
================================================
import { useEffect, useState } from "react";

const enum TailwindBreakpoint {
  sm = 640,
  md = 768,
  lg = 1024,
  xl = 1280,
  "2xl" = 1536,
}

export const useTailwindBreakpoints = () => {
  const [isSm, setIsSm] = useState(false);
  const [isMd, setIsMd] = useState(false);
  const [isLg, setIsLg] = useState(false);
  const [isXl, setIsXl] = useState(false);
  const [is2xl, setIs2xl] = useState(false);

  useEffect(() => {
    const handleResize = () => {
      const screenWidth = window.innerWidth;
      setIsSm(screenWidth >= TailwindBreakpoint.sm);
      setIsMd(screenWidth >= TailwindBreakpoint.md);
      setIsLg(screenWidth >= TailwindBreakpoint.lg);
      setIsXl(screenWidth >= TailwindBreakpoint.xl);
      setIs2xl(screenWidth >= TailwindBreakpoint["2xl"]);
    };
    handleResize();
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return { isSm, isMd, isLg, isXl, is2xl };
};


================================================
FILE: src/app/lib/make-object-char-iterator.ts
================================================
import { deepClone } from "lib/deep-clone";

type Object = { [key: string]: any };

/**
 * makeObjectCharIterator is a generator function that iterates a start object to
 * match an end object state by iterating through each string character.
 *
 * Note: Start object and end object must have the same structure and same keys.
 *       And they must have string or array or object as values.
 *
 * @example
 * const start = {a : ""}
 * const end = {a : "abc"};
 * const iterator = makeObjectCharIterator(start, end);
 * iterator.next().value // {a : "a"}
 * iterator.ne
Download .txt
gitextract_d24q13tt/

├── .dockerignore
├── .eslintrc.json
├── .gitignore
├── .prettierignore
├── Dockerfile
├── LICENSE
├── README.md
├── jest.config.mjs
├── next.config.js
├── package.json
├── postcss.config.js
├── prettier.config.js
├── prettierignore
├── public/
│   └── fonts/
│       ├── OFL.txt
│       ├── fonts-zh.css
│       └── fonts.css
├── src/
│   └── app/
│       ├── components/
│       │   ├── Button.tsx
│       │   ├── ExpanderWithHeightTransition.tsx
│       │   ├── FlexboxSpacer.tsx
│       │   ├── Resume/
│       │   │   ├── ResumeControlBar.tsx
│       │   │   ├── ResumeIFrame.tsx
│       │   │   ├── ResumePDF/
│       │   │   │   ├── ResumePDFCustom.tsx
│       │   │   │   ├── ResumePDFEducation.tsx
│       │   │   │   ├── ResumePDFProfile.tsx
│       │   │   │   ├── ResumePDFProject.tsx
│       │   │   │   ├── ResumePDFSkills.tsx
│       │   │   │   ├── ResumePDFWorkExperience.tsx
│       │   │   │   ├── common/
│       │   │   │   │   ├── ResumePDFIcon.tsx
│       │   │   │   │   ├── SuppressResumePDFErrorMessage.tsx
│       │   │   │   │   └── index.tsx
│       │   │   │   ├── index.tsx
│       │   │   │   └── styles.ts
│       │   │   ├── hooks.tsx
│       │   │   └── index.tsx
│       │   ├── ResumeDropzone.tsx
│       │   ├── ResumeForm/
│       │   │   ├── CustomForm.tsx
│       │   │   ├── EducationsForm.tsx
│       │   │   ├── Form/
│       │   │   │   ├── FeaturedSkillInput.tsx
│       │   │   │   ├── IconButton.tsx
│       │   │   │   ├── InputGroup.tsx
│       │   │   │   └── index.tsx
│       │   │   ├── ProfileForm.tsx
│       │   │   ├── ProjectsForm.tsx
│       │   │   ├── SkillsForm.tsx
│       │   │   ├── ThemeForm/
│       │   │   │   ├── InlineInput.tsx
│       │   │   │   ├── Selection.tsx
│       │   │   │   ├── constants.ts
│       │   │   │   └── index.tsx
│       │   │   ├── WorkExperiencesForm.tsx
│       │   │   ├── index.tsx
│       │   │   └── types.ts
│       │   ├── Tooltip.tsx
│       │   ├── TopNavBar.tsx
│       │   ├── documentation/
│       │   │   ├── Badge.tsx
│       │   │   ├── Heading.tsx
│       │   │   ├── Link.tsx
│       │   │   ├── Paragraph.tsx
│       │   │   ├── Table.tsx
│       │   │   └── index.tsx
│       │   └── fonts/
│       │       ├── FontsZh.tsx
│       │       ├── NonEnglishFontsCSSLoader.tsx
│       │       ├── constants.ts
│       │       ├── hooks.tsx
│       │       └── lib.ts
│       ├── globals-css.ts
│       ├── globals.css
│       ├── home/
│       │   ├── AutoTypingResume.tsx
│       │   ├── Features.tsx
│       │   ├── Hero.tsx
│       │   ├── LogoCloud.tsx
│       │   ├── QuestionsAndAnswers.tsx
│       │   ├── Steps.tsx
│       │   ├── Testimonials.tsx
│       │   └── constants.ts
│       ├── layout.tsx
│       ├── lib/
│       │   ├── __tests__/
│       │   │   ├── cx.test.ts
│       │   │   └── make-object-char-iterator.test.ts
│       │   ├── constants.ts
│       │   ├── cx.ts
│       │   ├── deep-clone.ts
│       │   ├── deep-merge.ts
│       │   ├── get-px-per-rem.ts
│       │   ├── hooks/
│       │   │   ├── useAutosizeTextareaHeight.tsx
│       │   │   └── useTailwindBreakpoints.tsx
│       │   ├── make-object-char-iterator.ts
│       │   ├── parse-resume-from-pdf/
│       │   │   ├── extract-resume-from-sections/
│       │   │   │   ├── extract-education.ts
│       │   │   │   ├── extract-profile.ts
│       │   │   │   ├── extract-project.ts
│       │   │   │   ├── extract-resume-from-sections.test.ts
│       │   │   │   ├── extract-skills.ts
│       │   │   │   ├── extract-work-experience.ts
│       │   │   │   ├── index.ts
│       │   │   │   └── lib/
│       │   │   │       ├── bullet-points.ts
│       │   │   │       ├── common-features.ts
│       │   │   │       ├── feature-scoring-system.ts
│       │   │   │       ├── get-section-lines.ts
│       │   │   │       └── subsections.ts
│       │   │   ├── group-lines-into-sections.ts
│       │   │   ├── group-text-items-into-lines.ts
│       │   │   ├── index.ts
│       │   │   ├── read-pdf.ts
│       │   │   └── types.ts
│       │   └── redux/
│       │       ├── hooks.tsx
│       │       ├── local-storage.ts
│       │       ├── resumeSlice.ts
│       │       ├── settingsSlice.ts
│       │       ├── store.ts
│       │       └── types.ts
│       ├── page.tsx
│       ├── resume-builder/
│       │   └── page.tsx
│       ├── resume-import/
│       │   └── page.tsx
│       └── resume-parser/
│           ├── ResumeParserAlgorithmArticle.tsx
│           ├── ResumeTable.tsx
│           └── page.tsx
├── tailwind.config.js
└── tsconfig.json
Download .txt
SYMBOL INDEX (119 symbols across 40 files)

FILE: src/app/components/Button.tsx
  type ReactButtonProps (line 4) | type ReactButtonProps = React.ComponentProps<"button">;
  type ReactAnchorProps (line 5) | type ReactAnchorProps = React.ComponentProps<"a">;
  type ButtonProps (line 6) | type ButtonProps = ReactButtonProps | ReactAnchorProps;
  type IconButtonProps (line 24) | type IconButtonProps = ButtonProps & {

FILE: src/app/components/Resume/ResumePDF/common/ResumePDFIcon.tsx
  constant EMAIL_PATH_D (line 8) | const EMAIL_PATH_D =
  constant PHONE_PATH_D (line 10) | const PHONE_PATH_D =
  constant LOCATION_PATH_D (line 12) | const LOCATION_PATH_D =
  constant URL_PATH_D (line 14) | const URL_PATH_D =
  constant GITHUB_PATH_D (line 16) | const GITHUB_PATH_D =
  constant LINKEDIN_PATH_D (line 18) | const LINKEDIN_PATH_D =
  constant TYPE_TO_PATH_D (line 20) | const TYPE_TO_PATH_D = {
  type IconType (line 29) | type IconType =

FILE: src/app/components/ResumeForm/Form/IconButton.tsx
  type MoveIconButtonType (line 32) | type MoveIconButtonType = "up" | "down";

FILE: src/app/components/ResumeForm/Form/InputGroup.tsx
  type InputProps (line 5) | interface InputProps<K extends string, V extends string | string[]> {
  constant INPUT_CLASS_NAME (line 35) | const INPUT_CLASS_NAME =
  constant NORMALIZED_LINE_BREAK (line 148) | const NORMALIZED_LINE_BREAK = "\n";

FILE: src/app/components/ResumeForm/Form/index.tsx
  constant FORM_TO_ICON (line 49) | const FORM_TO_ICON: { [section in ShowForm]: typeof BuildingOfficeIcon }...

FILE: src/app/components/ResumeForm/ThemeForm/InlineInput.tsx
  type InputProps (line 1) | interface InputProps<K extends string, V extends string> {

FILE: src/app/components/ResumeForm/ThemeForm/constants.ts
  constant THEME_COLORS (line 1) | const THEME_COLORS = [

FILE: src/app/components/ResumeForm/types.ts
  type CreateHandleChangeArgsWithDescriptions (line 1) | type CreateHandleChangeArgsWithDescriptions<T> =

FILE: src/app/components/documentation/Heading.tsx
  constant HEADING_CLASSNAMES (line 3) | const HEADING_CLASSNAMES = {

FILE: src/app/components/fonts/constants.ts
  constant SANS_SERIF_ENGLISH_FONT_FAMILIES (line 30) | const SANS_SERIF_ENGLISH_FONT_FAMILIES = [
  constant SERIF_ENGLISH_FONT_FAMILIES (line 38) | const SERIF_ENGLISH_FONT_FAMILIES = [
  constant ENGLISH_FONT_FAMILIES (line 46) | const ENGLISH_FONT_FAMILIES = [
  type EnglishFontFamily (line 50) | type EnglishFontFamily = (typeof ENGLISH_FONT_FAMILIES)[number];
  constant NON_ENGLISH_FONT_FAMILIES (line 52) | const NON_ENGLISH_FONT_FAMILIES = ["NotoSansSC"] as const;
  type NonEnglishFontFamily (line 53) | type NonEnglishFontFamily = (typeof NON_ENGLISH_FONT_FAMILIES)[number];
  constant NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE (line 55) | const NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE: Record<
  type FontFamily (line 62) | type FontFamily = EnglishFontFamily | NonEnglishFontFamily;
  constant FONT_FAMILY_TO_STANDARD_SIZE_IN_PT (line 63) | const FONT_FAMILY_TO_STANDARD_SIZE_IN_PT: Record<FontFamily, number> = {
  constant FONT_FAMILY_TO_DISPLAY_NAME (line 80) | const FONT_FAMILY_TO_DISPLAY_NAME: Record<FontFamily, string> = {

FILE: src/app/globals-css.ts
  constant CSS_VARIABLES (line 1) | const CSS_VARIABLES = {

FILE: src/app/home/AutoTypingResume.tsx
  constant INTERVAL_MS (line 13) | const INTERVAL_MS = 50;
  constant CHARS_PER_INTERVAL (line 14) | const CHARS_PER_INTERVAL = 10;
  constant RESET_INTERVAL_MS (line 20) | const RESET_INTERVAL_MS = 60 * 1000;

FILE: src/app/home/Features.tsx
  constant FEATURES (line 8) | const FEATURES = [

FILE: src/app/home/LogoCloud.tsx
  constant LOGOS (line 9) | const LOGOS = [

FILE: src/app/home/QuestionsAndAnswers.tsx
  constant QAS (line 3) | const QAS = [

FILE: src/app/home/Steps.tsx
  constant STEPS (line 1) | const STEPS = [

FILE: src/app/home/Testimonials.tsx
  constant TESTIMONIALS (line 10) | const TESTIMONIALS = [
  constant LG_TESTIMONIALS_CLASSNAMES (line 34) | const LG_TESTIMONIALS_CLASSNAMES = [
  constant SM_TESTIMONIALS_CLASSNAMES (line 39) | const SM_TESTIMONIALS_CLASSNAMES = ["z-10", "opacity-0", "opacity-0"];
  constant ROTATION_INTERVAL_MS (line 40) | const ROTATION_INTERVAL_MS = 8 * 1000;

FILE: src/app/home/constants.ts
  constant END_HOME_RESUME (line 10) | const END_HOME_RESUME: Resume = {
  constant START_HOME_RESUME (line 92) | const START_HOME_RESUME: Resume = {

FILE: src/app/layout.tsx
  function RootLayout (line 11) | function RootLayout({

FILE: src/app/lib/constants.ts
  constant PX_PER_PT (line 1) | const PX_PER_PT = 4 / 3;
  constant LETTER_WIDTH_PT (line 5) | const LETTER_WIDTH_PT = 612;
  constant LETTER_HEIGHT_PT (line 6) | const LETTER_HEIGHT_PT = 792;
  constant LETTER_WIDTH_PX (line 7) | const LETTER_WIDTH_PX = LETTER_WIDTH_PT * PX_PER_PT;
  constant LETTER_HEIGHT_PX (line 8) | const LETTER_HEIGHT_PX = LETTER_HEIGHT_PT * PX_PER_PT;
  constant A4_WIDTH_PT (line 11) | const A4_WIDTH_PT = 595;
  constant A4_HEIGHT_PT (line 12) | const A4_HEIGHT_PT = 842;
  constant A4_WIDTH_PX (line 13) | const A4_WIDTH_PX = A4_WIDTH_PT * PX_PER_PT;
  constant A4_HEIGHT_PX (line 14) | const A4_HEIGHT_PX = A4_HEIGHT_PT * PX_PER_PT;
  constant DEBUG_RESUME_PDF_FLAG (line 16) | const DEBUG_RESUME_PDF_FLAG: true | undefined = undefined;

FILE: src/app/lib/deep-merge.ts
  type Object (line 1) | type Object = { [key: string]: any };

FILE: src/app/lib/hooks/useTailwindBreakpoints.tsx
  type TailwindBreakpoint (line 3) | const enum TailwindBreakpoint {

FILE: src/app/lib/make-object-char-iterator.ts
  type Object (line 3) | type Object = { [key: string]: any };

FILE: src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-education.ts
  constant SCHOOLS (line 29) | const SCHOOLS = ['College', 'University', 'Institute', 'School', 'Academ...
  constant DEGREES (line 33) | const DEGREES = ["Associate", "Bachelor", "Master", "PhD", "Ph."];
  constant SCHOOL_FEATURE_SETS (line 46) | const SCHOOL_FEATURE_SETS: FeatureSet[] = [
  constant DEGREE_FEATURE_SETS (line 52) | const DEGREE_FEATURE_SETS: FeatureSet[] = [
  constant GPA_FEATURE_SETS (line 58) | const GPA_FEATURE_SETS: FeatureSet[] = [

FILE: src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-profile.ts
  constant NAME_FEATURE_SETS (line 65) | const NAME_FEATURE_SETS: FeatureSet[] = [
  constant EMAIL_FEATURE_SETS (line 79) | const EMAIL_FEATURE_SETS: FeatureSet[] = [
  constant PHONE_FEATURE_SETS (line 90) | const PHONE_FEATURE_SETS: FeatureSet[] = [
  constant LOCATION_FEATURE_SETS (line 96) | const LOCATION_FEATURE_SETS: FeatureSet[] = [
  constant URL_FEATURE_SETS (line 105) | const URL_FEATURE_SETS: FeatureSet[] = [
  constant SUMMARY_FEATURE_SETS (line 117) | const SUMMARY_FEATURE_SETS: FeatureSet[] = [

FILE: src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-work-experience.ts
  constant WORK_EXPERIENCE_KEYWORDS_LOWERCASE (line 22) | const WORK_EXPERIENCE_KEYWORDS_LOWERCASE = ['work', 'experience', 'emplo...
  constant JOB_TITLES (line 24) | const JOB_TITLES = ['Accountant', 'Administrator', 'Advisor', 'Agent', '...
  constant JOB_TITLE_FEATURE_SET (line 31) | const JOB_TITLE_FEATURE_SET: FeatureSet[] = [

FILE: src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points.ts
  constant BULLET_POINTS (line 17) | const BULLET_POINTS = [

FILE: src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features.ts
  constant MONTHS (line 19) | const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', ...
  constant SEASONS (line 25) | const SEASONS = ["Summer", "Fall", "Spring", "Winter"];
  constant DATE_FEATURE_SETS (line 29) | const DATE_FEATURE_SETS: FeatureSet[] = [

FILE: src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections.ts
  type IsLineNewSubsection (line 42) | type IsLineNewSubsection = (line: Line, prevLine: Line) => boolean;

FILE: src/app/lib/parse-resume-from-pdf/group-lines-into-sections.ts
  constant PROFILE_SECTION (line 13) | const PROFILE_SECTION: ResumeKey = "profile";
  constant SECTION_TITLE_PRIMARY_KEYWORDS (line 44) | const SECTION_TITLE_PRIMARY_KEYWORDS = [
  constant SECTION_TITLE_SECONDARY_KEYWORDS (line 50) | const SECTION_TITLE_SECONDARY_KEYWORDS = [
  constant SECTION_TITLE_KEYWORDS (line 60) | const SECTION_TITLE_KEYWORDS = [

FILE: src/app/lib/parse-resume-from-pdf/types.ts
  type TextItem (line 3) | interface TextItem {
  type TextItems (line 12) | type TextItems = TextItem[];
  type Line (line 14) | type Line = TextItem[];
  type Lines (line 15) | type Lines = Line[];
  type ResumeSectionToLines (line 17) | type ResumeSectionToLines = { [sectionName in ResumeKey]?: Lines } & {
  type Subsections (line 20) | type Subsections = Lines[];
  type FeatureScore (line 22) | type FeatureScore = -4 | -3 | -2 | -1 | 0 | 1 | 2 | 3 | 4;
  type ReturnMatchingTextOnly (line 23) | type ReturnMatchingTextOnly = boolean;
  type FeatureSet (line 24) | type FeatureSet =
  type TextScore (line 32) | interface TextScore {
  type TextScores (line 37) | type TextScores = TextScore[];

FILE: src/app/lib/redux/local-storage.ts
  constant LOCAL_STORAGE_KEY (line 5) | const LOCAL_STORAGE_KEY = "open-resume-state";

FILE: src/app/lib/redux/resumeSlice.ts
  type CreateChangeActionWithDescriptions (line 67) | type CreateChangeActionWithDescriptions<T> = {

FILE: src/app/lib/redux/settingsSlice.ts
  type Settings (line 4) | interface Settings {
  type ShowForm (line 32) | type ShowForm = keyof Settings["formToShow"];
  type FormWithBulletPoints (line 33) | type FormWithBulletPoints = keyof Settings["showBulletPoints"];
  type GeneralSetting (line 34) | type GeneralSetting = Exclude<
  constant DEFAULT_THEME_COLOR (line 39) | const DEFAULT_THEME_COLOR = "#38bdf8";
  constant DEFAULT_FONT_FAMILY (line 40) | const DEFAULT_FONT_FAMILY = "Roboto";
  constant DEFAULT_FONT_SIZE (line 41) | const DEFAULT_FONT_SIZE = "11";
  constant DEFAULT_FONT_COLOR (line 42) | const DEFAULT_FONT_COLOR = "#171717";

FILE: src/app/lib/redux/store.ts
  type RootState (line 12) | type RootState = ReturnType<typeof store.getState>;
  type AppDispatch (line 13) | type AppDispatch = typeof store.dispatch;

FILE: src/app/lib/redux/types.ts
  type ResumeProfile (line 1) | interface ResumeProfile {
  type ResumeWorkExperience (line 10) | interface ResumeWorkExperience {
  type ResumeEducation (line 17) | interface ResumeEducation {
  type ResumeProject (line 25) | interface ResumeProject {
  type FeaturedSkill (line 31) | interface FeaturedSkill {
  type ResumeSkills (line 36) | interface ResumeSkills {
  type ResumeCustom (line 41) | interface ResumeCustom {
  type Resume (line 45) | interface Resume {
  type ResumeKey (line 54) | type ResumeKey = keyof Resume;

FILE: src/app/page.tsx
  function Home (line 7) | function Home() {

FILE: src/app/resume-builder/page.tsx
  function Create (line 7) | function Create() {

FILE: src/app/resume-import/page.tsx
  function ImportResume (line 7) | function ImportResume() {

FILE: src/app/resume-parser/page.tsx
  constant RESUME_EXAMPLES (line 15) | const RESUME_EXAMPLES = [
  function ResumeParser (line 39) | function ResumeParser() {
Condensed preview — 116 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (301K chars).
[
  {
    "path": ".dockerignore",
    "chars": 31,
    "preview": "node_modules\n.next\n.vscode\n.git"
  },
  {
    "path": ".eslintrc.json",
    "chars": 99,
    "preview": "{\n  \"extends\": \"next/core-web-vitals\",\n  \"rules\": {\n    \"react/no-unescaped-entities\": \"off\"\n  }\n}\n"
  },
  {
    "path": ".gitignore",
    "chars": 368,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": ".prettierignore",
    "chars": 28,
    "preview": ".next\npublic/fonts/fonts.css"
  },
  {
    "path": "Dockerfile",
    "chars": 321,
    "preview": "FROM node:18-alpine as builder\nWORKDIR /app\nCOPY . .\nRUN npm install --include=dev\nRUN npm run build\n\nFROM node:18-alpin"
  },
  {
    "path": "LICENSE",
    "chars": 34522,
    "preview": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C)"
  },
  {
    "path": "README.md",
    "chars": 5448,
    "preview": "# OpenResume\n\nOpenResume is a powerful open-source resume builder and resume parser.\n\nThe goal of OpenResume is to provi"
  },
  {
    "path": "jest.config.mjs",
    "chars": 603,
    "preview": "import nextJest from \"next/jest.js\";\n\nconst createJestConfig = nextJest({\n  // Provide the path to your Next.js app to l"
  },
  {
    "path": "next.config.js",
    "chars": 718,
    "preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  // Nextjs has an issue with pdfjs-dist which optionally "
  },
  {
    "path": "package.json",
    "chars": 1208,
    "preview": "{\n  \"name\": \"open-resume\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \""
  },
  {
    "path": "postcss.config.js",
    "chars": 82,
    "preview": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "prettier.config.js",
    "chars": 146,
    "preview": "module.exports = {\n  // Repo: https://github.com/tailwindlabs/prettier-plugin-tailwindcss\n  plugins: [require(\"prettier-"
  },
  {
    "path": "prettierignore",
    "chars": 18,
    "preview": "public/fonts/*.css"
  },
  {
    "path": "public/fonts/OFL.txt",
    "chars": 5886,
    "preview": "Copyright (c), Christian Robertson (https://fonts.google.com/specimen/Roboto/about?query=Roboto),\nwith Reserved Font Nam"
  },
  {
    "path": "public/fonts/fonts-zh.css",
    "chars": 181,
    "preview": "@font-face {font-family: \"NotoSansSC\"; src: url(\"/fonts/NotoSansSC-Regular.ttf\");}\n@font-face {font-family: \"NotoSansSC\""
  },
  {
    "path": "public/fonts/fonts.css",
    "chars": 1879,
    "preview": "/* Adding a new English font family needs to keep \"public\\fonts\\fonts.ts\" in sync */\n/* Sans Serif Fonts */\n@font-face {"
  },
  {
    "path": "src/app/components/Button.tsx",
    "chars": 1135,
    "preview": "import { cx } from \"lib/cx\";\nimport { Tooltip } from \"components/Tooltip\";\n\ntype ReactButtonProps = React.ComponentProps"
  },
  {
    "path": "src/app/components/ExpanderWithHeightTransition.tsx",
    "chars": 843,
    "preview": "/**\n * ExpanderWithHeightTransition is a div wrapper with built-in transition animation based on height.\n * If expanded "
  },
  {
    "path": "src/app/components/FlexboxSpacer.tsx",
    "chars": 495,
    "preview": "/**\n * FlexboxSpacer can be used to create empty space in flex.\n * It is a div that grows to fill the available space sp"
  },
  {
    "path": "src/app/components/Resume/ResumeControlBar.tsx",
    "chars": 2465,
    "preview": "\"use client\";\nimport { useEffect } from \"react\";\nimport { useSetDefaultScale } from \"components/Resume/hooks\";\nimport {\n"
  },
  {
    "path": "src/app/components/Resume/ResumeIFrame.tsx",
    "chars": 3575,
    "preview": "\"use client\";\nimport { useMemo } from \"react\";\nimport Frame from \"react-frame-component\";\nimport {\n  A4_HEIGHT_PX,\n  A4_"
  },
  {
    "path": "src/app/components/Resume/ResumePDF/ResumePDFCustom.tsx",
    "chars": 770,
    "preview": "import { View } from \"@react-pdf/renderer\";\nimport {\n  ResumePDFSection,\n  ResumePDFBulletList,\n} from \"components/Resum"
  },
  {
    "path": "src/app/components/Resume/ResumePDF/ResumePDFEducation.tsx",
    "chars": 1981,
    "preview": "import { View } from \"@react-pdf/renderer\";\nimport {\n  ResumePDFBulletList,\n  ResumePDFSection,\n  ResumePDFText,\n} from "
  },
  {
    "path": "src/app/components/Resume/ResumePDF/ResumePDFProfile.tsx",
    "chars": 2769,
    "preview": "import { View } from \"@react-pdf/renderer\";\nimport {\n  ResumePDFIcon,\n  type IconType,\n} from \"components/Resume/ResumeP"
  },
  {
    "path": "src/app/components/Resume/ResumePDF/ResumePDFProject.tsx",
    "chars": 1088,
    "preview": "import { View } from \"@react-pdf/renderer\";\nimport {\n  ResumePDFSection,\n  ResumePDFBulletList,\n  ResumePDFText,\n} from "
  },
  {
    "path": "src/app/components/Resume/ResumePDF/ResumePDFSkills.tsx",
    "chars": 1973,
    "preview": "import { View } from \"@react-pdf/renderer\";\nimport {\n  ResumePDFSection,\n  ResumePDFBulletList,\n  ResumeFeaturedSkill,\n}"
  },
  {
    "path": "src/app/components/Resume/ResumePDF/ResumePDFWorkExperience.tsx",
    "chars": 1601,
    "preview": "import { View } from \"@react-pdf/renderer\";\nimport {\n  ResumePDFSection,\n  ResumePDFBulletList,\n  ResumePDFText,\n} from "
  },
  {
    "path": "src/app/components/Resume/ResumePDF/common/ResumePDFIcon.tsx",
    "chars": 4525,
    "preview": "import { Svg, Path } from \"@react-pdf/renderer\";\nimport { styles } from \"components/Resume/ResumePDF/styles\";\n\n/**\n * Fo"
  },
  {
    "path": "src/app/components/Resume/ResumePDF/common/SuppressResumePDFErrorMessage.tsx",
    "chars": 545,
    "preview": "\"use client\";\n\n/**\n * Suppress ResumePDF development errors.\n * See ResumePDF doc string for context.\n */\nif (typeof win"
  },
  {
    "path": "src/app/components/Resume/ResumePDF/common/index.tsx",
    "chars": 3812,
    "preview": "import { Text, View, Link } from \"@react-pdf/renderer\";\nimport type { Style } from \"@react-pdf/types\";\nimport { styles, "
  },
  {
    "path": "src/app/components/Resume/ResumePDF/index.tsx",
    "chars": 4679,
    "preview": "import { Page, View, Document } from \"@react-pdf/renderer\";\nimport { styles, spacing } from \"components/Resume/ResumePDF"
  },
  {
    "path": "src/app/components/Resume/ResumePDF/styles.ts",
    "chars": 1154,
    "preview": "import { StyleSheet } from \"@react-pdf/renderer\";\n\n// Tailwindcss Spacing Design System: https://tailwindcss.com/docs/th"
  },
  {
    "path": "src/app/components/Resume/hooks.tsx",
    "chars": 2094,
    "preview": "import { useEffect, useState } from \"react\";\nimport { A4_HEIGHT_PX, LETTER_HEIGHT_PX } from \"lib/constants\";\nimport { ge"
  },
  {
    "path": "src/app/components/Resume/index.tsx",
    "chars": 2240,
    "preview": "\"use client\";\nimport { useState, useMemo } from \"react\";\nimport { ResumeIframeCSR } from \"components/Resume/ResumeIFrame"
  },
  {
    "path": "src/app/components/ResumeDropzone.tsx",
    "chars": 6381,
    "preview": "import { useState } from \"react\";\nimport { LockClosedIcon } from \"@heroicons/react/24/solid\";\nimport { XMarkIcon } from "
  },
  {
    "path": "src/app/components/ResumeForm/CustomForm.tsx",
    "chars": 1673,
    "preview": "import { Form } from \"components/ResumeForm/Form\";\nimport { BulletListIconButton } from \"components/ResumeForm/Form/Icon"
  },
  {
    "path": "src/app/components/ResumeForm/EducationsForm.tsx",
    "chars": 3663,
    "preview": "import { Form, FormSection } from \"components/ResumeForm/Form\";\nimport {\n  BulletListTextarea,\n  Input,\n} from \"componen"
  },
  {
    "path": "src/app/components/ResumeForm/Form/FeaturedSkillInput.tsx",
    "chars": 1868,
    "preview": "import React, { useState } from \"react\";\nimport { INPUT_CLASS_NAME } from \"components/ResumeForm/Form/InputGroup\";\n\nexpo"
  },
  {
    "path": "src/app/components/ResumeForm/Form/IconButton.tsx",
    "chars": 2450,
    "preview": "import { IconButton } from \"components/Button\";\nimport {\n  EyeIcon,\n  EyeSlashIcon,\n  ArrowSmallUpIcon,\n  ArrowSmallDown"
  },
  {
    "path": "src/app/components/ResumeForm/Form/InputGroup.tsx",
    "chars": 8309,
    "preview": "import { useState, useEffect } from \"react\";\nimport ContentEditable from \"react-contenteditable\";\nimport { useAutosizeTe"
  },
  {
    "path": "src/app/components/ResumeForm/Form/index.tsx",
    "chars": 5836,
    "preview": "import { ExpanderWithHeightTransition } from \"components/ExpanderWithHeightTransition\";\nimport {\n  DeleteIconButton,\n  M"
  },
  {
    "path": "src/app/components/ResumeForm/ProfileForm.tsx",
    "chars": 2119,
    "preview": "import { BaseForm } from \"components/ResumeForm/Form\";\nimport { Input, Textarea } from \"components/ResumeForm/Form/Input"
  },
  {
    "path": "src/app/components/ResumeForm/ProjectsForm.tsx",
    "chars": 2214,
    "preview": "import { Form, FormSection } from \"components/ResumeForm/Form\";\nimport {\n  Input,\n  BulletListTextarea,\n} from \"componen"
  },
  {
    "path": "src/app/components/ResumeForm/SkillsForm.tsx",
    "chars": 2960,
    "preview": "import { Form } from \"components/ResumeForm/Form\";\nimport {\n  BulletListTextarea,\n  InputGroupWrapper,\n} from \"component"
  },
  {
    "path": "src/app/components/ResumeForm/ThemeForm/InlineInput.tsx",
    "chars": 887,
    "preview": "interface InputProps<K extends string, V extends string> {\n  label: string;\n  labelClassName?: string;\n  name: K;\n  valu"
  },
  {
    "path": "src/app/components/ResumeForm/ThemeForm/Selection.tsx",
    "chars": 4460,
    "preview": "import type { GeneralSetting } from \"lib/redux/settingsSlice\";\nimport { PX_PER_PT } from \"lib/constants\";\nimport {\n  FON"
  },
  {
    "path": "src/app/components/ResumeForm/ThemeForm/constants.ts",
    "chars": 341,
    "preview": "export const THEME_COLORS = [\n  \"#f87171\", // Red-400\n  \"#ef4444\", // Red-500\n  \"#fb923c\", // Orange-400\n  \"#f97316\", //"
  },
  {
    "path": "src/app/components/ResumeForm/ThemeForm/index.tsx",
    "chars": 3491,
    "preview": "import { BaseForm } from \"components/ResumeForm/Form\";\nimport { InputGroupWrapper } from \"components/ResumeForm/Form/Inp"
  },
  {
    "path": "src/app/components/ResumeForm/WorkExperiencesForm.tsx",
    "chars": 2801,
    "preview": "import { Form, FormSection } from \"components/ResumeForm/Form\";\nimport {\n  Input,\n  BulletListTextarea,\n} from \"componen"
  },
  {
    "path": "src/app/components/ResumeForm/index.tsx",
    "chars": 1974,
    "preview": "\"use client\";\nimport { useState } from \"react\";\nimport {\n  useAppSelector,\n  useSaveStateToLocalStorageOnChange,\n  useSe"
  },
  {
    "path": "src/app/components/ResumeForm/types.ts",
    "chars": 163,
    "preview": "export type CreateHandleChangeArgsWithDescriptions<T> =\n  | [field: Exclude<keyof T, \"descriptions\">, value: string]\n  |"
  },
  {
    "path": "src/app/components/Tooltip.tsx",
    "chars": 1970,
    "preview": "\"use client\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\n\n/**\n * A s"
  },
  {
    "path": "src/app/components/TopNavBar.tsx",
    "chars": 1725,
    "preview": "\"use client\";\nimport { usePathname } from \"next/navigation\";\nimport Link from \"next/link\";\nimport Image from \"next/image"
  },
  {
    "path": "src/app/components/documentation/Badge.tsx",
    "chars": 256,
    "preview": "export const Badge = ({ children }: { children: React.ReactNode }) => (\n  <span className=\"inline-flex rounded-md bg-blu"
  },
  {
    "path": "src/app/components/documentation/Heading.tsx",
    "chars": 561,
    "preview": "import { cx } from \"lib/cx\";\n\nconst HEADING_CLASSNAMES = {\n  1: \"text-2xl font-bold\",\n  2: \"text-xl font-bold\",\n  3: \"te"
  },
  {
    "path": "src/app/components/documentation/Link.tsx",
    "chars": 376,
    "preview": "import { cx } from \"lib/cx\";\n\nexport const Link = ({\n  href,\n  children,\n  className = \"\",\n}: {\n  href: string;\n  childr"
  },
  {
    "path": "src/app/components/documentation/Paragraph.tsx",
    "chars": 399,
    "preview": "import { cx } from \"lib/cx\";\n\nexport const Paragraph = ({\n  smallMarginTop = false,\n  children,\n  className = \"\",\n}: {\n "
  },
  {
    "path": "src/app/components/documentation/Table.tsx",
    "chars": 1508,
    "preview": "import { cx } from \"lib/cx\";\n\nexport const Table = ({\n  table,\n  title,\n  className,\n  trClassNames = [],\n  tdClassNames"
  },
  {
    "path": "src/app/components/documentation/index.tsx",
    "chars": 342,
    "preview": "import { Heading } from \"components/documentation/Heading\";\nimport { Paragraph } from \"components/documentation/Paragrap"
  },
  {
    "path": "src/app/components/fonts/FontsZh.tsx",
    "chars": 155,
    "preview": "import \"public/fonts/fonts-zh.css\";\n\n/**\n * Empty component. Main purpose is to load fonts-zh.css\n */\nconst FontsZh = ()"
  },
  {
    "path": "src/app/components/fonts/NonEnglishFontsCSSLoader.tsx",
    "chars": 728,
    "preview": "import { useState, useEffect } from \"react\";\nimport dynamic from \"next/dynamic\";\nimport { getAllFontFamiliesToLoad } fro"
  },
  {
    "path": "src/app/components/fonts/constants.ts",
    "chars": 3427,
    "preview": "/**\n * Adding a new font family involves 4 steps:\n * Step 1. Add it to one of the below FONT_FAMILIES variable array:\n *"
  },
  {
    "path": "src/app/components/fonts/hooks.tsx",
    "chars": 1547,
    "preview": "import { useEffect } from \"react\";\nimport { Font } from \"@react-pdf/renderer\";\nimport { ENGLISH_FONT_FAMILIES } from \"co"
  },
  {
    "path": "src/app/components/fonts/lib.ts",
    "chars": 871,
    "preview": "\"use client\";\nimport {\n  ENGLISH_FONT_FAMILIES,\n  NON_ENGLISH_FONT_FAMILIES,\n  NON_ENGLISH_FONT_FAMILY_TO_LANGUAGE,\n} fr"
  },
  {
    "path": "src/app/globals-css.ts",
    "chars": 152,
    "preview": "export const CSS_VARIABLES = {\n  \"--top-nav-bar-height\": \"3.5rem\",\n  \"--resume-control-bar-height\": \"3rem\",\n  \"--resume-"
  },
  {
    "path": "src/app/globals.css",
    "chars": 1440,
    "preview": "@import url(\"/fonts/fonts.css\");\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer components {\n  .btn-"
  },
  {
    "path": "src/app/home/AutoTypingResume.tsx",
    "chars": 2895,
    "preview": "\"use client\";\nimport { useEffect, useState, useRef } from \"react\";\nimport { ResumePDF } from \"components/Resume/ResumePD"
  },
  {
    "path": "src/app/home/Features.tsx",
    "chars": 2112,
    "preview": "import Image from \"next/image\";\nimport featureFreeSrc from \"public/assets/feature-free.svg\";\nimport featureUSSrc from \"p"
  },
  {
    "path": "src/app/home/Hero.tsx",
    "chars": 1453,
    "preview": "import Link from \"next/link\";\nimport { FlexboxSpacer } from \"components/FlexboxSpacer\";\nimport { AutoTypingResume } from"
  },
  {
    "path": "src/app/home/LogoCloud.tsx",
    "chars": 1430,
    "preview": "import logoCornellSrc from \"public/assets/logo-cornell.svg\";\nimport logoColumbiaSrc from \"public/assets/logo-columbia.sv"
  },
  {
    "path": "src/app/home/QuestionsAndAnswers.tsx",
    "chars": 6238,
    "preview": "import { Link } from \"components/documentation\";\n\nconst QAS = [\n  {\n    question:\n      \"Q1. What is a resume builder? W"
  },
  {
    "path": "src/app/home/Steps.tsx",
    "chars": 1313,
    "preview": "const STEPS = [\n  { title: \"Add a resume pdf\", text: \"or create from scratch\" },\n  { title: \"Preview design\", text: \"and"
  },
  {
    "path": "src/app/home/Testimonials.tsx",
    "chars": 5167,
    "preview": "\"use client\";\nimport heartSrc from \"public/assets/heart.svg\";\nimport testimonialSpiegelSrc from \"public/assets/testimoni"
  },
  {
    "path": "src/app/home/constants.ts",
    "chars": 3683,
    "preview": "import {\n  initialEducation,\n  initialProfile,\n  initialProject,\n  initialWorkExperience,\n} from \"lib/redux/resumeSlice\""
  },
  {
    "path": "src/app/layout.tsx",
    "chars": 741,
    "preview": "import \"globals.css\";\nimport { TopNavBar } from \"components/TopNavBar\";\nimport { Analytics } from \"@vercel/analytics/rea"
  },
  {
    "path": "src/app/lib/__tests__/cx.test.ts",
    "chars": 218,
    "preview": "import { cx } from \"lib/cx\";\n\ntest(\"cx\", () => {\n  expect(cx(\"px-1\", \"mt-2\")).toEqual(\"px-1 mt-2\");\n  expect(cx(\"px-1\", "
  },
  {
    "path": "src/app/lib/__tests__/make-object-char-iterator.test.ts",
    "chars": 825,
    "preview": "import { makeObjectCharIterator } from \"lib/make-object-char-iterator\";\n\ntest(\"Simple object\", () => {\n  const start = {"
  },
  {
    "path": "src/app/lib/constants.ts",
    "chars": 744,
    "preview": "export const PX_PER_PT = 4 / 3;\n\n// Reference: https://www.prepressure.com/library/paper-size/letter\n// Letter size is c"
  },
  {
    "path": "src/app/lib/cx.ts",
    "chars": 628,
    "preview": "/**\n * cx is a simple util to join classNames together. Think of it as a simplified version of the open source classname"
  },
  {
    "path": "src/app/lib/deep-clone.ts",
    "chars": 310,
    "preview": "/**\n * Server side object deep clone util using JSON serialization.\n * Not efficient for large objects but good enough f"
  },
  {
    "path": "src/app/lib/deep-merge.ts",
    "chars": 959,
    "preview": "type Object = { [key: string]: any };\n\nconst isObject = (item: any): item is Object => {\n  return item && typeof item =="
  },
  {
    "path": "src/app/lib/get-px-per-rem.ts",
    "chars": 191,
    "preview": "export const getPxPerRem = () => {\n  const bodyComputedStyle = getComputedStyle(\n    document.querySelector(\"body\")!\n  )"
  },
  {
    "path": "src/app/lib/hooks/useAutosizeTextareaHeight.tsx",
    "chars": 1060,
    "preview": "import { useEffect, useRef } from \"react\";\n\n/**\n * Hook to autosize textarea height.\n *\n * The trick to resize is to fir"
  },
  {
    "path": "src/app/lib/hooks/useTailwindBreakpoints.tsx",
    "chars": 986,
    "preview": "import { useEffect, useState } from \"react\";\n\nconst enum TailwindBreakpoint {\n  sm = 640,\n  md = 768,\n  lg = 1024,\n  xl "
  },
  {
    "path": "src/app/lib/make-object-char-iterator.ts",
    "chars": 1762,
    "preview": "import { deepClone } from \"lib/deep-clone\";\n\ntype Object = { [key: string]: any };\n\n/**\n * makeObjectCharIterator is a g"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-education.ts",
    "chars": 3711,
    "preview": "import type {\n  TextItem,\n  FeatureSet,\n  ResumeSectionToLines,\n} from \"lib/parse-resume-from-pdf/types\";\nimport type { "
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-profile.ts",
    "chars": 5562,
    "preview": "import type {\n  ResumeSectionToLines,\n  TextItem,\n  FeatureSet,\n} from \"lib/parse-resume-from-pdf/types\";\nimport { getSe"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-project.ts",
    "chars": 1992,
    "preview": "import type { ResumeProject } from \"lib/redux/types\";\nimport type {\n  FeatureSet,\n  ResumeSectionToLines,\n} from \"lib/pa"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-resume-from-sections.test.ts",
    "chars": 993,
    "preview": "import {\n  matchOnlyLetterSpaceOrPeriod,\n  matchEmail,\n  matchPhone,\n  matchUrl,\n} from \"lib/parse-resume-from-pdf/extra"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-skills.ts",
    "chars": 1369,
    "preview": "import type { ResumeSkills } from \"lib/redux/types\";\nimport type { ResumeSectionToLines } from \"lib/parse-resume-from-pd"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-work-experience.ts",
    "chars": 3643,
    "preview": "import type { ResumeWorkExperience } from \"lib/redux/types\";\nimport type {\n  TextItem,\n  FeatureSet,\n  ResumeSectionToLi"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/index.ts",
    "chars": 1968,
    "preview": "import type { Resume } from \"lib/redux/types\";\nimport type { ResumeSectionToLines } from \"lib/parse-resume-from-pdf/type"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points.ts",
    "chars": 3560,
    "preview": "import type { Lines, TextItem } from \"lib/parse-resume-from-pdf/types\";\n\n/**\n * List of bullet points\n * Reference: http"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features.ts",
    "chars": 1518,
    "preview": "import type { TextItem, FeatureSet } from \"lib/parse-resume-from-pdf/types\";\n\nconst isTextItemBold = (fontName: string) "
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system.ts",
    "chars": 2333,
    "preview": "import type {\n  TextItems,\n  TextScores,\n  FeatureSet,\n} from \"lib/parse-resume-from-pdf/types\";\n\nconst computeFeatureSc"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines.ts",
    "chars": 480,
    "preview": "import type { ResumeSectionToLines } from \"lib/parse-resume-from-pdf/types\";\n\n/**\n * Return section lines that contain a"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections.ts",
    "chars": 3289,
    "preview": "import { BULLET_POINTS } from \"lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points\";\nimport { isBol"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/group-lines-into-sections.ts",
    "chars": 2907,
    "preview": "import type { ResumeKey } from \"lib/redux/types\";\nimport type {\n  Line,\n  Lines,\n  ResumeSectionToLines,\n} from \"lib/par"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/group-text-items-into-lines.ts",
    "chars": 4735,
    "preview": "import { BULLET_POINTS } from \"lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points\";\nimport type { "
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/index.ts",
    "chars": 1004,
    "preview": "import { readPdf } from \"lib/parse-resume-from-pdf/read-pdf\";\nimport { groupTextItemsIntoLines } from \"lib/parse-resume-"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/read-pdf.ts",
    "chars": 3367,
    "preview": "// Getting pdfjs to work is tricky. The following 3 lines would make it work\n// https://stackoverflow.com/a/63486898/769"
  },
  {
    "path": "src/app/lib/parse-resume-from-pdf/types.ts",
    "chars": 853,
    "preview": "import type { ResumeKey } from \"lib/redux/types\";\n\nexport interface TextItem {\n  text: string;\n  x: number;\n  y: number;"
  },
  {
    "path": "src/app/lib/redux/hooks.tsx",
    "chars": 1719,
    "preview": "import { useEffect } from \"react\";\nimport {\n  useDispatch,\n  useSelector,\n  type TypedUseSelectorHook,\n} from \"react-red"
  },
  {
    "path": "src/app/lib/redux/local-storage.ts",
    "chars": 756,
    "preview": "import type { RootState } from \"lib/redux/store\";\n\n// Reference: https://dev.to/igorovic/simplest-way-to-persist-redux-s"
  },
  {
    "path": "src/app/lib/redux/resumeSlice.ts",
    "chars": 6229,
    "preview": "import { createSlice, type PayloadAction } from \"@reduxjs/toolkit\";\nimport type { RootState } from \"lib/redux/store\";\nim"
  },
  {
    "path": "src/app/lib/redux/settingsSlice.ts",
    "chars": 4773,
    "preview": "import { createSlice, type PayloadAction } from \"@reduxjs/toolkit\";\nimport type { RootState } from \"lib/redux/store\";\n\ne"
  },
  {
    "path": "src/app/lib/redux/store.ts",
    "chars": 385,
    "preview": "import { configureStore } from \"@reduxjs/toolkit\";\nimport resumeReducer from \"lib/redux/resumeSlice\";\nimport settingsRed"
  },
  {
    "path": "src/app/lib/redux/types.ts",
    "chars": 966,
    "preview": "export interface ResumeProfile {\n  name: string;\n  email: string;\n  phone: string;\n  url: string;\n  summary: string;\n  l"
  },
  {
    "path": "src/app/page.tsx",
    "chars": 482,
    "preview": "import { Hero } from \"home/Hero\";\nimport { Steps } from \"home/Steps\";\nimport { Features } from \"home/Features\";\nimport {"
  },
  {
    "path": "src/app/resume-builder/page.tsx",
    "chars": 614,
    "preview": "\"use client\";\nimport { Provider } from \"react-redux\";\nimport { store } from \"lib/redux/store\";\nimport { ResumeForm } fro"
  },
  {
    "path": "src/app/resume-import/page.tsx",
    "chars": 2739,
    "preview": "\"use client\";\nimport { getHasUsedAppBefore } from \"lib/redux/local-storage\";\nimport { ResumeDropzone } from \"components/"
  },
  {
    "path": "src/app/resume-parser/ResumeParserAlgorithmArticle.tsx",
    "chars": 19012,
    "preview": "import { isBold } from \"lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features\";\nimport {\n  Badge,\n "
  },
  {
    "path": "src/app/resume-parser/ResumeTable.tsx",
    "chars": 4309,
    "preview": "import { Fragment } from \"react\";\nimport type { Resume } from \"lib/redux/types\";\nimport { initialEducation, initialWorkE"
  },
  {
    "path": "src/app/resume-parser/page.tsx",
    "chars": 5432,
    "preview": "\"use client\";\nimport { useState, useEffect } from \"react\";\nimport { readPdf } from \"lib/parse-resume-from-pdf/read-pdf\";"
  },
  {
    "path": "tailwind.config.js",
    "chars": 386,
    "preview": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: [\"./src/app/**/*.{ts,tsx,mdx}\"],\n  theme: {\n  "
  },
  {
    "path": "tsconfig.json",
    "chars": 806,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"sk"
  }
]

About this extraction

This page contains the full source code of the xitanggg/open-resume GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 116 files (274.4 KB), approximately 73.6k tokens, and a symbol index with 119 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!