mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 14:57:00 +10:00
feat: add Meowth template with inline three-column entry header (Asian-style compact ATS) (#2923)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import { GlalieTemplate } from "./templates/glalie";
|
||||
import { KakunaTemplate } from "./templates/kakuna";
|
||||
import { LaprasTemplate } from "./templates/lapras";
|
||||
import { LeafishTemplate } from "./templates/leafish";
|
||||
import { MeowthTemplate } from "./templates/meowth";
|
||||
import { OnyxTemplate } from "./templates/onyx";
|
||||
import { PikachuTemplate } from "./templates/pikachu";
|
||||
import { RhyhornTemplate } from "./templates/rhyhorn";
|
||||
@@ -72,6 +73,7 @@ function getTemplateComponent(template: Template) {
|
||||
.with("kakuna", () => KakunaTemplate)
|
||||
.with("lapras", () => LaprasTemplate)
|
||||
.with("leafish", () => LeafishTemplate)
|
||||
.with("meowth", () => MeowthTemplate)
|
||||
.with("onyx", () => OnyxTemplate)
|
||||
.with("pikachu", () => PikachuTemplate)
|
||||
.with("rhyhorn", () => RhyhornTemplate)
|
||||
|
||||
@@ -29,18 +29,46 @@ import { VolunteerItem } from "./items/volunteer-item";
|
||||
import { PageSection } from "./page-section";
|
||||
import { PageSummary } from "./page-summary";
|
||||
|
||||
/**
|
||||
* Extra props forwarded by a template to each rendered item. Used by templates
|
||||
* (e.g., Meowth) to opt-in to alternative item renderings such as an inline
|
||||
* three-column header. Only item components that declare the corresponding
|
||||
* prop will react to it; others simply ignore the extra props via spread.
|
||||
*/
|
||||
type ItemProps = {
|
||||
headerLayout?: "split" | "inline";
|
||||
};
|
||||
|
||||
type SectionComponentProps = {
|
||||
sectionClassName?: string;
|
||||
itemClassName?: string;
|
||||
itemProps?: ItemProps;
|
||||
};
|
||||
|
||||
// Helper to render item component based on type
|
||||
function renderItemByType(type: CustomSectionType, item: CustomSectionItem, itemClassName?: string) {
|
||||
function renderItemByType(
|
||||
type: CustomSectionType,
|
||||
item: CustomSectionItem,
|
||||
itemClassName?: string,
|
||||
itemProps?: ItemProps,
|
||||
) {
|
||||
return match(type)
|
||||
.with("summary", () => <SummaryItem {...(item as SummaryItemType)} className={itemClassName} />)
|
||||
.with("profiles", () => <ProfilesItem {...(item as SectionItem<"profiles">)} className={itemClassName} />)
|
||||
.with("experience", () => <ExperienceItem {...(item as SectionItem<"experience">)} className={itemClassName} />)
|
||||
.with("education", () => <EducationItem {...(item as SectionItem<"education">)} className={itemClassName} />)
|
||||
.with("experience", () => (
|
||||
<ExperienceItem
|
||||
{...(item as SectionItem<"experience">)}
|
||||
className={itemClassName}
|
||||
headerLayout={itemProps?.headerLayout}
|
||||
/>
|
||||
))
|
||||
.with("education", () => (
|
||||
<EducationItem
|
||||
{...(item as SectionItem<"education">)}
|
||||
className={itemClassName}
|
||||
headerLayout={itemProps?.headerLayout}
|
||||
/>
|
||||
))
|
||||
.with("projects", () => <ProjectsItem {...(item as SectionItem<"projects">)} className={itemClassName} />)
|
||||
.with("skills", () => <SkillsItem {...(item as SectionItem<"skills">)} className={itemClassName} />)
|
||||
.with("languages", () => <LanguagesItem {...(item as SectionItem<"languages">)} className={itemClassName} />)
|
||||
@@ -52,7 +80,13 @@ function renderItemByType(type: CustomSectionType, item: CustomSectionItem, item
|
||||
.with("publications", () => (
|
||||
<PublicationsItem {...(item as SectionItem<"publications">)} className={itemClassName} />
|
||||
))
|
||||
.with("volunteer", () => <VolunteerItem {...(item as SectionItem<"volunteer">)} className={itemClassName} />)
|
||||
.with("volunteer", () => (
|
||||
<VolunteerItem
|
||||
{...(item as SectionItem<"volunteer">)}
|
||||
className={itemClassName}
|
||||
headerLayout={itemProps?.headerLayout}
|
||||
/>
|
||||
))
|
||||
.with("references", () => <ReferencesItem {...(item as SectionItem<"references">)} className={itemClassName} />)
|
||||
.with("cover-letter", () => <CoverLetterItem {...(item as CoverLetterItemType)} className={itemClassName} />)
|
||||
.exhaustive();
|
||||
@@ -62,7 +96,7 @@ type SectionProps = { id: string };
|
||||
|
||||
export function getSectionComponent(
|
||||
section: "summary" | SectionType | (string & {}),
|
||||
{ sectionClassName, itemClassName }: SectionComponentProps = {},
|
||||
{ sectionClassName, itemClassName, itemProps }: SectionComponentProps = {},
|
||||
) {
|
||||
return match(section)
|
||||
.with("summary", () => {
|
||||
@@ -82,7 +116,7 @@ export function getSectionComponent(
|
||||
.with("experience", () => {
|
||||
const ExperienceSection = (_: SectionProps) => (
|
||||
<PageSection type="experience" className={sectionClassName}>
|
||||
{(item) => <ExperienceItem {...item} className={itemClassName} />}
|
||||
{(item) => <ExperienceItem {...item} className={itemClassName} headerLayout={itemProps?.headerLayout} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
@@ -91,7 +125,7 @@ export function getSectionComponent(
|
||||
.with("education", () => {
|
||||
const EducationSection = (_: SectionProps) => (
|
||||
<PageSection type="education" className={sectionClassName}>
|
||||
{(item) => <EducationItem {...item} className={itemClassName} />}
|
||||
{(item) => <EducationItem {...item} className={itemClassName} headerLayout={itemProps?.headerLayout} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
@@ -163,7 +197,7 @@ export function getSectionComponent(
|
||||
.with("volunteer", () => {
|
||||
const VolunteerSection = (_: SectionProps) => (
|
||||
<PageSection type="volunteer" className={sectionClassName}>
|
||||
{(item) => <VolunteerItem {...item} className={itemClassName} />}
|
||||
{(item) => <VolunteerItem {...item} className={itemClassName} headerLayout={itemProps?.headerLayout} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
@@ -205,7 +239,7 @@ export function getSectionComponent(
|
||||
key={item.id}
|
||||
className={cn(`section-item section-item-${customSection.type} print:break-inside-avoid`)}
|
||||
>
|
||||
{renderItemByType(customSection.type, item, itemClassName)}
|
||||
{renderItemByType(customSection.type, item, itemClassName, itemProps)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type InlineHeaderProps = {
|
||||
/** Leading (start) column — typically the position/major. */
|
||||
leading?: React.ReactNode;
|
||||
/** Middle column — typically the company/school (primary title). */
|
||||
middle?: React.ReactNode;
|
||||
/** Trailing (end) column — typically the period/date. Kept at auto width. */
|
||||
trailing?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A three-column inline header used by templates that prefer a compact,
|
||||
* single-line entry header (common in Asian resume conventions where
|
||||
* position/major · organization · period are traditionally laid out on
|
||||
* the same line).
|
||||
*
|
||||
* Layout: [leading] [middle] [trailing]
|
||||
* - leading / middle: flexible, `minmax(0, 1fr)` so they shrink/grow together
|
||||
* and wrap naturally when content is too long to fit on one line. Wrapping
|
||||
* preserves the three-column alignment because all columns share the same
|
||||
* grid row and are top-aligned via `items-start`.
|
||||
* - trailing: `auto` width, end-aligned — suitable for short strings like
|
||||
* periods/dates that should stay on one line.
|
||||
*/
|
||||
export function InlineHeader({ leading, middle, trailing, className }: InlineHeaderProps) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-x-3", className)}>
|
||||
<div className="min-w-0">{leading}</div>
|
||||
<div className="min-w-0">{middle}</div>
|
||||
<div className="shrink-0 text-end">{trailing}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,24 @@ import { filterFieldValues } from "@/utils/field";
|
||||
import { stripHtml } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { InlineHeader } from "../inline-header";
|
||||
import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type EducationItemProps = SectionItem<"education"> & {
|
||||
className?: string;
|
||||
/**
|
||||
* Controls the header layout of each entry.
|
||||
* - `"split"` (default): two-column layout — school/area on the start side,
|
||||
* degree/location-period on the end side.
|
||||
* - `"inline"`: single-row, three-column layout — `[area (degree · grade)]`,
|
||||
* `[school]`, `[period]`. Used by templates targeting compact single-line
|
||||
* entry headers (e.g., Asian resume conventions).
|
||||
*/
|
||||
headerLayout?: "split" | "inline";
|
||||
};
|
||||
|
||||
export function EducationItem({ className, ...item }: EducationItemProps) {
|
||||
export function EducationItem({ className, headerLayout = "split", ...item }: EducationItemProps) {
|
||||
const degreeAndGrade = [item.degree, item.grade].filter(Boolean).join(" • ");
|
||||
const locationAndPeriod = [item.location, item.period].filter(Boolean).join(" • ");
|
||||
const headerValues = {
|
||||
@@ -48,21 +58,72 @@ export function EducationItem({ className, ...item }: EducationItemProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const renderInlineHeader = () => {
|
||||
// Leading cell keeps the header on a single line by only combining the
|
||||
// two most essential fields: major (`area`) and degree. Grade and location
|
||||
// are demoted to a secondary metadata line below the header so they don't
|
||||
// force the header row to wrap on narrow grids.
|
||||
const hasArea = Boolean(item.area?.trim());
|
||||
const hasDegree = Boolean(item.degree?.trim());
|
||||
|
||||
const leading =
|
||||
hasArea || hasDegree ? (
|
||||
<span className="section-item-metadata education-item-area-degree">
|
||||
{hasArea && <span className="education-item-area">{item.area}</span>}
|
||||
{hasArea && hasDegree && " "}
|
||||
{hasDegree && <span className="education-item-degree opacity-80">({item.degree})</span>}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const middle = (
|
||||
<LinkedTitle
|
||||
title={item.school}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title education-item-title"
|
||||
/>
|
||||
);
|
||||
|
||||
const trailing = item.period ? (
|
||||
<span className="section-item-metadata education-item-period whitespace-nowrap">{item.period}</span>
|
||||
) : null;
|
||||
|
||||
// Secondary line below the header: grade · location (when either exists).
|
||||
// Mirrors the "degree · grade / location · period" pairing used by the
|
||||
// default split layout, but fitted for a single-column template.
|
||||
const gradeAndLocation = [item.grade, item.location].filter(Boolean).join(" • ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineHeader leading={leading} middle={middle} trailing={trailing} />
|
||||
{gradeAndLocation && (
|
||||
<div className="section-item-metadata education-item-grade-location mt-0.5 opacity-80">
|
||||
{gradeAndLocation}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSplitHeader = () => (
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||
{headerFields.get("school")?.content}
|
||||
{headerFields.get("area")?.content}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">
|
||||
{headerFields.get("degreeAndGrade")?.content}
|
||||
{headerFields.get("locationAndPeriod")?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("education-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header education-item-header mb-2">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||
{headerFields.get("school")?.content}
|
||||
{headerFields.get("area")?.content}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">
|
||||
{headerFields.get("degreeAndGrade")?.content}
|
||||
{headerFields.get("locationAndPeriod")?.content}
|
||||
</div>
|
||||
</div>
|
||||
{headerLayout === "inline" ? renderInlineHeader() : renderSplitHeader()}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
|
||||
@@ -5,14 +5,25 @@ import { filterFieldValues } from "@/utils/field";
|
||||
import { stripHtml } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { InlineHeader } from "../inline-header";
|
||||
import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type ExperienceItemProps = SectionItem<"experience"> & {
|
||||
className?: string;
|
||||
/**
|
||||
* Controls the header layout of each entry.
|
||||
* - `"split"` (default): two-column layout — company/position on the start side,
|
||||
* location/period on the end side. This preserves the behavior used by all
|
||||
* existing templates.
|
||||
* - `"inline"`: single-row, three-column layout — `[position (location)]`,
|
||||
* `[company]`, `[period]`. Used by templates targeting compact single-line
|
||||
* entry headers (e.g., Asian resume conventions).
|
||||
*/
|
||||
headerLayout?: "split" | "inline";
|
||||
};
|
||||
|
||||
export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
|
||||
export function ExperienceItem({ className, headerLayout = "split", ...item }: ExperienceItemProps) {
|
||||
const hasRoles = Array.isArray(item.roles) && item.roles.length > 0;
|
||||
const headerValues = {
|
||||
company: item.company,
|
||||
@@ -46,21 +57,57 @@ export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
|
||||
content: <span className="section-item-metadata experience-item-period">{item.period}</span>,
|
||||
},
|
||||
);
|
||||
|
||||
const renderInlineHeader = () => {
|
||||
// Combine position and location into a single leading cell so the header
|
||||
// stays on one line, e.g. "Frontend Engineer (Guangzhou)".
|
||||
const hasPosition = Boolean(item.position?.trim());
|
||||
const hasLocation = Boolean(item.location?.trim());
|
||||
|
||||
const leading =
|
||||
hasPosition || hasLocation ? (
|
||||
<span className="section-item-metadata experience-item-position-location">
|
||||
{hasPosition && <span className="experience-item-position">{item.position}</span>}
|
||||
{hasPosition && hasLocation && " "}
|
||||
{hasLocation && <span className="experience-item-location opacity-80">({item.location})</span>}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const middle = (
|
||||
<LinkedTitle
|
||||
title={item.company}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title experience-item-title"
|
||||
/>
|
||||
);
|
||||
|
||||
const trailing = item.period ? (
|
||||
<span className="section-item-metadata experience-item-period whitespace-nowrap">{item.period}</span>
|
||||
) : null;
|
||||
|
||||
return <InlineHeader leading={leading} middle={middle} trailing={trailing} />;
|
||||
};
|
||||
|
||||
const renderSplitHeader = () => (
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||
{headerFields.get("company")?.content}
|
||||
{headerFields.get("position")?.content}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">
|
||||
{headerFields.get("location")?.content}
|
||||
{headerFields.get("period")?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("experience-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header experience-item-header">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||
{headerFields.get("company")?.content}
|
||||
{headerFields.get("position")?.content}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">
|
||||
{headerFields.get("location")?.content}
|
||||
{headerFields.get("period")?.content}
|
||||
</div>
|
||||
</div>
|
||||
{headerLayout === "inline" ? renderInlineHeader() : renderSplitHeader()}
|
||||
</div>
|
||||
|
||||
{/* Role Progression */}
|
||||
|
||||
@@ -5,14 +5,27 @@ import { filterFieldValues } from "@/utils/field";
|
||||
import { stripHtml } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { InlineHeader } from "../inline-header";
|
||||
import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type VolunteerItemProps = SectionItem<"volunteer"> & {
|
||||
className?: string;
|
||||
/**
|
||||
* Controls the header layout of each entry.
|
||||
* - `"split"` (default): two-column layout — organization/location on the
|
||||
* start side, period on the end side.
|
||||
* - `"inline"`: single-row, three-column layout — `[role (location)]`,
|
||||
* `[organization]`, `[period]`. Used by templates targeting compact
|
||||
* single-line entry headers (e.g., Asian resume conventions).
|
||||
*
|
||||
* Note: The volunteer schema doesn't have a dedicated "role" field; when
|
||||
* inline layout is requested, the leading column falls back to the location.
|
||||
*/
|
||||
headerLayout?: "split" | "inline";
|
||||
};
|
||||
|
||||
export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
|
||||
export function VolunteerItem({ className, headerLayout = "split", ...item }: VolunteerItemProps) {
|
||||
const headerValues = {
|
||||
organization: item.organization,
|
||||
period: item.period,
|
||||
@@ -41,18 +54,30 @@ export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const renderInlineHeader = () => (
|
||||
<InlineHeader
|
||||
leading={headerFields.get("location")?.content}
|
||||
middle={headerFields.get("organization")?.content}
|
||||
trailing={headerFields.get("period")?.content}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderSplitHeader = () => (
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||
{headerFields.get("organization")?.content}
|
||||
{headerFields.get("location")?.content}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">{headerFields.get("period")?.content}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("volunteer-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header volunteer-item-header">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||
{headerFields.get("organization")?.content}
|
||||
{headerFields.get("location")?.content}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 flex-col items-end text-end">{headerFields.get("period")?.content}</div>
|
||||
</div>
|
||||
{headerLayout === "inline" ? renderInlineHeader() : renderSplitHeader()}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
// Section headings: uppercase, tracked, with a thin primary-colored underline.
|
||||
// Kept ATS-friendly (plain text, no sidebar decoration).
|
||||
const sectionClassName = cn(
|
||||
"[&>h6]:mb-1 [&>h6]:border-b [&>h6]:border-(--page-primary-color) [&>h6]:pb-0.5",
|
||||
"[&>h6]:tracking-wide [&>h6]:text-(--page-primary-color) [&>h6]:uppercase",
|
||||
);
|
||||
|
||||
// Opt-in to the inline three-column header for experience / education / volunteer items.
|
||||
const itemProps = { headerLayout: "inline" } as const;
|
||||
|
||||
/**
|
||||
* Template: Meowth
|
||||
*
|
||||
* A single-column, ATS-friendly template with an inline three-column entry
|
||||
* header (position/major · organization · period), optimized for Asian
|
||||
* (Chinese / Japanese / Korean) resume conventions where these three
|
||||
* fields traditionally share a single line per entry.
|
||||
*/
|
||||
export function MeowthTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-meowth page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName, itemProps });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName, itemProps });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex items-start gap-x-4">
|
||||
<div className="page-basics flex min-w-0 flex-1 flex-col gap-y-2">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline opacity-80">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PagePicture />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,13 @@ export const templates = {
|
||||
tags: ["Two-column", "Muted sidebar", "Earthy", "Calm", "Sustainability", "Healthcare", "Nonprofit"],
|
||||
sidebarPosition: "right",
|
||||
},
|
||||
meowth: {
|
||||
name: "Meowth",
|
||||
description: msg`Single-column with an inline three-column entry header (position · organization · period); compact and ATS-friendly, well-suited for Asian resume conventions (CN/JP/KR).`,
|
||||
imageUrl: "/templates/jpg/meowth.jpg",
|
||||
tags: ["Single-column", "ATS friendly", "Inline header", "Compact", "Asian style", "CN/JP/KR"],
|
||||
sidebarPosition: "none",
|
||||
},
|
||||
onyx: {
|
||||
name: "Onyx",
|
||||
description: msg`Single-column with a sidebar and clean grid layout; versatile for any professional or technical role.`,
|
||||
|
||||
@@ -138,6 +138,7 @@ const CSS_SELECTORS = [
|
||||
".template-kakuna",
|
||||
".template-lapras",
|
||||
".template-leafish",
|
||||
".template-meowth",
|
||||
".template-onyx",
|
||||
".template-pikachu",
|
||||
".template-rhyhorn",
|
||||
|
||||
@@ -11,6 +11,7 @@ export const templateSchema = z.enum([
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn",
|
||||
@@ -33,6 +34,7 @@ export const printMarginTemplates = [
|
||||
"bronzor",
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn",
|
||||
|
||||
Reference in New Issue
Block a user