refactor(v4.0.0-alpha): beginning of a new era

This commit is contained in:
Amruth Pillai
2023-11-05 12:31:42 +01:00
parent 0ba6a444e2
commit 22933bd412
505 changed files with 81829 additions and 0 deletions

View File

@ -0,0 +1,89 @@
import { CircleNotch, FileJs, FilePdf } from "@phosphor-icons/react";
import { buttonVariants, Card, CardContent, CardDescription, CardTitle } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { saveAs } from "file-saver";
import { useToast } from "@/client/hooks/use-toast";
import { usePrintResume } from "@/client/services/resume/print";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const ExportSection = () => {
const { toast } = useToast();
const { printResume, loading } = usePrintResume();
const onJsonExport = () => {
const { resume } = useResumeStore.getState();
const filename = `reactive_resume-${resume.id}.json`;
const resumeJSON = JSON.stringify(resume.data, null, 2);
saveAs(new Blob([resumeJSON], { type: "application/json" }), filename);
toast({
variant: "success",
title: "A JSON snapshot of your resume has been successfully exported.",
});
};
const onPdfExport = async () => {
const { resume } = useResumeStore.getState();
const { url } = await printResume({ id: resume.id });
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
openInNewTab(url);
};
return (
<section id="export" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("export")}
<h2 className="line-clamp-1 text-3xl font-bold">Export</h2>
</div>
</header>
<main className="grid gap-y-4">
<Card
onClick={onJsonExport}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-auto cursor-pointer flex-row items-center gap-x-5 px-4 pb-3 pt-1",
)}
>
<FileJs size={22} />
<CardContent className="flex-1">
<CardTitle className="text-sm">JSON</CardTitle>
<CardDescription className="font-normal">
Download a JSON snapshot of your resume. This file can be used to import your resume
in the future, or can even be shared with others to collaborate.
</CardDescription>
</CardContent>
</Card>
<Card
onClick={onPdfExport}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-auto cursor-pointer flex-row items-center gap-x-5 px-4 pb-3 pt-1",
loading && "pointer-events-none cursor-progress opacity-75",
)}
>
{loading ? <CircleNotch size={22} className="animate-spin" /> : <FilePdf size={22} />}
<CardContent className="flex-1">
<CardTitle className="text-sm">PDF</CardTitle>
<CardDescription className="font-normal">
Download a PDF of your resume. This file can be used to print your resume, send it to
recruiters, or upload on job portals.
</CardDescription>
</CardContent>
</Card>
</main>
</section>
);
};

View File

@ -0,0 +1,129 @@
import { Book, EnvelopeSimpleOpen, GithubLogo, HandHeart } from "@phosphor-icons/react";
import {
buttonVariants,
Card,
CardContent,
CardDescription,
CardFooter,
CardTitle,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { getSectionIcon } from "../shared/section-icon";
const DonateCard = () => (
<Card className="space-y-4 bg-info text-info-foreground">
<CardContent className="space-y-2">
<CardTitle>Support the app by donating what you can!</CardTitle>
<CardDescription className="space-y-2">
<p>
I built Reactive Resume mostly by myself during my spare time, with a lot of help from
other great open-source contributors.
</p>
<p>
If you like the app and want to support keeping it free forever, please donate whatever
you can afford to give.
</p>
</CardDescription>
</CardContent>
<CardFooter>
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://github.com/sponsors/AmruthPillai"
target="_blank"
rel="noopener noreferrer nofollow"
>
<HandHeart size={14} weight="bold" className="mr-2" />
<span>Donate to Reactive Resume</span>
</a>
</CardFooter>
</Card>
);
const IssuesCard = () => (
<Card className="space-y-4">
<CardContent className="space-y-2">
<CardTitle>Found a bug, or have an idea for a new feature?</CardTitle>
<CardDescription className="space-y-2">
<p>I'm sure the app is not perfect, but I'd like for it to be.</p>
<p>
If you faced any issues while creating your resume, or have an idea that would help you
and other users in creating your resume more easily, drop an issue on the repository or
send me an email about it.
</p>
</CardDescription>
</CardContent>
<CardFooter className="space-x-4">
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://github.com/AmruthPillai/Reactive-Resume/issues/new/choose"
target="_blank"
rel="noopener noreferrer nofollow"
>
<GithubLogo size={14} weight="bold" className="mr-2" />
<span>Raise an issue</span>
</a>
<a
className={cn(buttonVariants({ size: "sm" }))}
href="mailto:hello@amruthpillai.com"
target="_blank"
rel="noopener noreferrer nofollow"
>
<EnvelopeSimpleOpen size={14} weight="bold" className="mr-2" />
<span>Send me a message</span>
</a>
</CardFooter>
</Card>
);
const DocumentationCard = () => (
<Card className="space-y-4">
<CardContent className="space-y-2">
<CardTitle>Don't know where to begin? Hit the docs!</CardTitle>
<CardDescription className="space-y-2">
<p>
The community has spent a lot of time writing the documentation for Reactive Resume, and
I'm sure it will help you get started with the app.
</p>
<p>
There are also a lot of examples to help you get started, and features that you might not
know about which could help you build your perfect resume.
</p>
</CardDescription>
</CardContent>
<CardFooter className="space-x-4">
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://docs.rxresu.me/"
target="_blank"
rel="noopener noreferrer nofollow"
>
<Book size={14} weight="bold" className="mr-2" />
<span>Documentation</span>
</a>
</CardFooter>
</Card>
);
export const InformationSection = () => {
return (
<section id="information" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("information")}
<h2 className="line-clamp-1 text-3xl font-bold">Information</h2>
</div>
</header>
<main className="grid gap-y-4">
<DonateCard />
<DocumentationCard />
<IssuesCard />
</main>
</section>
);
};

View File

@ -0,0 +1,269 @@
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ArrowCounterClockwise, DotsSixVertical, Plus, TrashSimple } from "@phosphor-icons/react";
import { defaultMetadata } from "@reactive-resume/schema";
import { Button, Portal, Tooltip } from "@reactive-resume/ui";
import {
cn,
LayoutLocator,
moveItemInLayout,
parseLayoutLocator,
SortablePayload,
} from "@reactive-resume/utils";
import get from "lodash.get";
import { useState } from "react";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
type ColumnProps = {
id: string;
name: string;
items: string[];
};
const Column = ({ id, name, items }: ColumnProps) => {
const { setNodeRef } = useDroppable({ id });
return (
<SortableContext id={id} items={items} strategy={verticalListSortingStrategy}>
<div className="relative">
<div className="absolute inset-0 w-3/4 rounded bg-secondary/50" />
<div className="relative z-10 p-3 pb-8">
<p className="mb-3 text-xs font-bold">{name}</p>
<div ref={setNodeRef} className="space-y-3">
{items.map((section) => (
<SortableSection key={section} id={section} />
))}
</div>
</div>
</div>
</SortableContext>
);
};
type SortableSectionProps = {
id: string;
};
const SortableSection = ({ id }: SortableSectionProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = {
transition,
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
};
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
<Section id={id} />
</div>
);
};
type SectionProps = {
id: string;
isDragging?: boolean;
};
const Section = ({ id, isDragging = false }: SectionProps) => {
const name = useResumeStore((state) =>
get(state.resume.data.sections, `${id}.name`, id),
) as string;
return (
<div
className={cn(
"cursor-grab rounded bg-primary p-2 text-primary-foreground transition-colors hover:bg-primary-accent",
isDragging && "cursor-grabbing",
)}
>
<div className="flex items-center gap-x-2">
<DotsSixVertical size={12} weight="bold" />
<p className="flex-1 truncate text-xs font-medium">{name}</p>
</div>
</div>
);
};
export const LayoutSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const layout = useResumeStore((state) => state.resume.data.metadata.layout);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const onDragStart = ({ active }: DragStartEvent) => {
setActiveId(active.id as string);
};
const onDragCancel = () => {
setActiveId(null);
};
const onDragEvent = ({ active, over }: DragOverEvent | DragEndEvent) => {
if (!over || !active.data.current) return;
const currentPayload = active.data.current.sortable as SortablePayload | null;
const current = parseLayoutLocator(currentPayload);
if (active.id === over.id) return;
if (!over.data.current) {
const [page, column] = (over.id as string).split(".").map(Number);
const target = { page, column, section: 0 } as LayoutLocator;
const newLayout = moveItemInLayout(current, target, layout);
setValue("metadata.layout", newLayout);
return;
}
const targetPayload = over.data.current.sortable as SortablePayload | null;
const target = parseLayoutLocator(targetPayload);
const newLayout = moveItemInLayout(current, target, layout);
setValue("metadata.layout", newLayout);
};
const onDragEnd = (event: DragEndEvent) => {
onDragEvent(event);
setActiveId(null);
};
const onAddPage = () => {
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
layoutCopy.push([[], []]);
setValue("metadata.layout", layoutCopy);
};
const onRemovePage = (page: number) => {
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
layoutCopy[0][0].push(...layoutCopy[page][0]); // Main
layoutCopy[0][1].push(...layoutCopy[page][1]); // Sidebar
layoutCopy.splice(page, 1);
setValue("metadata.layout", layoutCopy);
};
const onResetLayout = () => {
const layoutCopy = JSON.parse(JSON.stringify(defaultMetadata.layout)) as string[][][];
// Loop through all pages and columns, and get any sections that start with "custom."
// These should be appended to the first page of the new layout.
const customSections: string[] = [];
layout.forEach((page) => {
page.forEach((column) => {
customSections.push(...column.filter((section) => section.startsWith("custom.")));
});
});
if (customSections.length > 0) layoutCopy[0][0].push(...customSections);
setValue("metadata.layout", layoutCopy);
};
return (
<section id="layout" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("layout")}
<h2 className="line-clamp-1 text-3xl font-bold">Layout</h2>
</div>
<Tooltip content="Reset Layout">
<Button size="icon" variant="ghost" onClick={onResetLayout}>
<ArrowCounterClockwise />
</Button>
</Tooltip>
</header>
<main className="grid gap-y-4">
{/* Pages */}
<DndContext
sensors={sensors}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
{layout.map((page, pageIndex) => {
const mainIndex = `${pageIndex}.0`;
const sidebarIndex = `${pageIndex}.1`;
const main = page[0];
const sidebar = page[1];
return (
<div key={pageIndex} className="rounded border p-3 pb-4">
<div className="flex items-center justify-between">
<p className="mb-3 text-xs font-bold">Page {pageIndex + 1}</p>
{pageIndex !== 0 && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => onRemovePage(pageIndex)}
>
<TrashSimple size={12} />
</Button>
)}
</div>
<div className="grid grid-cols-2 items-start gap-x-4">
<Column id={mainIndex} name="Main" items={main} />
<Column id={sidebarIndex} name="Sidebar" items={sidebar} />
</div>
</div>
);
})}
<Portal>
<DragOverlay>{activeId && <Section id={activeId} isDragging />}</DragOverlay>
</Portal>
</DndContext>
<Button variant="outline" className="ml-auto" onClick={onAddPage}>
<Plus />
<span className="ml-2">Add New Page</span>
</Button>
</main>
</section>
);
};

View File

@ -0,0 +1,97 @@
import {
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Slider,
Switch,
} from "@reactive-resume/ui";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const PageSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const page = useResumeStore((state) => state.resume.data.metadata.page);
return (
<section id="theme" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("page")}
<h2 className="line-clamp-1 text-3xl font-bold">Page</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="space-y-1.5">
<Label>Format</Label>
<Select
value={page.format}
onValueChange={(value) => {
setValue("metadata.page.format", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a4">A4</SelectItem>
<SelectItem value="letter">Letter</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Margin</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={0}
max={48}
step={2}
value={[page.margin]}
onValueChange={(value) => {
setValue("metadata.page.margin", value[0]);
}}
/>
<span className="text-base font-bold">{page.margin}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Options</Label>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.page.options.breakLine"
checked={page.options.breakLine}
onCheckedChange={(checked) => {
setValue("metadata.page.options.breakLine", checked);
}}
/>
<Label htmlFor="metadata.page.options.breakLine">Show Break Line</Label>
</div>
</div>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.page.options.pageNumbers"
checked={page.options.pageNumbers}
onCheckedChange={(checked) => {
setValue("metadata.page.options.pageNumbers", checked);
}}
/>
<Label htmlFor="metadata.page.options.pageNumbers">Show Page Numbers</Label>
</div>
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,90 @@
import { CopySimple } from "@phosphor-icons/react";
import { Button, Input, Label, Switch, Tooltip } from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useToast } from "@/client/hooks/use-toast";
import { useUser } from "@/client/services/user";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const SharingSection = () => {
const { user } = useUser();
const { toast } = useToast();
const username = user?.username;
const setValue = useResumeStore((state) => state.setValue);
const slug = useResumeStore((state) => state.resume.slug);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
// Constants
const url = `${window.location.origin}/${username}/${slug}`;
const onCopy = async () => {
await navigator.clipboard.writeText(url);
toast({
variant: "success",
title: "A link has been copied to your clipboard.",
description:
"Anyone with this link can view and download the resume. Share it on your profile or with recruiters.",
});
};
return (
<section id="sharing" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("sharing")}
<h2 className="line-clamp-1 text-3xl font-bold">Sharing</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="space-y-1.5">
<div className="flex items-center gap-x-4">
<Switch
id="visibility"
checked={isPublic}
onCheckedChange={(checked) => {
setValue("visibility", checked ? "public" : "private");
}}
/>
<div>
<Label htmlFor="visibility" className="space-y-1">
<p>Public</p>
<p className="text-xs opacity-60">
Anyone with the link can view and download the resume.
</p>
</Label>
</div>
</div>
</div>
<AnimatePresence presenceAffectsLayout>
{isPublic && (
<motion.div
layout
className="space-y-1.5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Label htmlFor="resume-url">URL</Label>
<div className="flex gap-x-1.5">
<Input id="resume-url" readOnly value={url} className="flex-1" />
<Tooltip content="Copy to Clipboard">
<Button size="icon" variant="ghost" onClick={onCopy}>
<CopySimple />
</Button>
</Tooltip>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</section>
);
};

View File

@ -0,0 +1,65 @@
import { Info } from "@phosphor-icons/react";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useResumeStatistics } from "@/client/services/resume";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const StatisticsSection = () => {
const id = useResumeStore((state) => state.resume.id);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
const { statistics } = useResumeStatistics(id, isPublic);
return (
<section id="statistics" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("statistics")}
<h2 className="line-clamp-1 text-3xl font-bold">Statistics</h2>
</div>
</header>
<main className="grid grid-cols-2 gap-y-4">
<AnimatePresence>
{!isPublic && (
<motion.div
className="col-span-2"
initial={{ opacity: 0, y: -50, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -50, filter: "blur(10px)" }}
>
<Alert variant="info">
<Info size={18} />
<AlertTitle>Statistics are available only for public resumes.</AlertTitle>
<AlertDescription className="text-xs leading-relaxed">
You can track the number of views your resume has received, or how many people
have downloaded the resume by enabling public sharing.
</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
<div>
<h3 className={cn("text-4xl font-bold blur-none transition-all", !isPublic && "blur-sm")}>
{statistics?.views ?? 0}
</h3>
<p className="opacity-75">Views</p>
</div>
<div>
<h3 className={cn("text-4xl font-bold blur-none transition-all", !isPublic && "blur-sm")}>
{statistics?.downloads ?? 0}
</h3>
<p className="opacity-75">Downloads</p>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,42 @@
import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const TemplateSection = () => {
// TODO: Import templates from @reactive-resume/templates
const templateList = ["rhyhorn"];
const setValue = useResumeStore((state) => state.setValue);
const currentTemplate = useResumeStore((state) => state.resume.data.metadata.template);
return (
<section id="template" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("template")}
<h2 className="line-clamp-1 text-3xl font-bold">Template</h2>
</div>
</header>
<main className="grid grid-cols-2 gap-y-4">
{templateList.map((template) => (
<Button
key={template}
variant="outline"
disabled={template === currentTemplate}
onClick={() => setValue("metadata.template", template)}
className={cn(
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm capitalize ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
template === currentTemplate && "ring-1",
)}
>
{template}
</Button>
))}
</main>
</section>
);
};

View File

@ -0,0 +1,133 @@
import { Input, Label, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { HexColorPicker } from "react-colorful";
import { colors } from "@/client/constants/colors";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const ThemeSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const theme = useResumeStore((state) => state.resume.data.metadata.theme);
return (
<section id="theme" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("theme")}
<h2 className="line-clamp-1 text-3xl font-bold">Theme</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="mb-2 grid grid-cols-6 flex-wrap justify-items-center gap-y-4 @xs/right:grid-cols-9">
{colors.map((color) => (
<div
key={color}
onClick={() => {
setValue("metadata.theme.primary", color);
}}
className={cn(
"flex h-6 w-6 cursor-pointer items-center justify-center rounded-full ring-primary ring-offset-1 ring-offset-background transition-shadow hover:ring-1",
theme.primary === color && "ring-1",
)}
>
<div className="h-5 w-5 rounded-full" style={{ backgroundColor: color }} />
</div>
))}
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Primary Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.primary }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.primary}
onChange={(color) => {
setValue("metadata.theme.primary", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.primary"
value={theme.primary}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.primary", event.target.value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Background Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.background }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.background}
onChange={(color) => {
setValue("metadata.theme.background", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.background"
value={theme.background}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.background", event.target.value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Text Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.text }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.text}
onChange={(color) => {
setValue("metadata.theme.text", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.text"
value={theme.text}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.text", event.target.value);
}}
/>
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,183 @@
import { Button, Combobox, ComboboxOption, Label, Slider, Switch } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { fonts } from "@reactive-resume/utils";
import { useCallback, useEffect, useState } from "react";
import webfontloader from "webfontloader";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
const fontSuggestions = [
"Open Sans",
"Merriweather",
"CMU Serif",
"Playfair Display",
"Lato",
"Lora",
"PT Sans",
"PT Serif",
"IBM Plex Sans",
"IBM Plex Serif",
];
const families: ComboboxOption[] = fonts.map((font) => ({
value: font.family,
label: font.family,
}));
export const TypographySection = () => {
const [subsets, setSubsets] = useState<ComboboxOption[]>([]);
const [variants, setVariants] = useState<ComboboxOption[]>([]);
const setValue = useResumeStore((state) => state.setValue);
const typography = useResumeStore((state) => state.resume.data.metadata.typography);
const loadFontSuggestions = useCallback(async () => {
fontSuggestions.forEach((font) => {
if (font === "CMU Serif") return;
webfontloader.load({
events: false,
classes: false,
google: { families: [font], text: font },
});
});
}, [fontSuggestions]);
useEffect(() => {
loadFontSuggestions();
}, []);
useEffect(() => {
const subsets = fonts.find((font) => font.family === typography.font.family)?.subsets ?? [];
setSubsets(subsets.map((subset) => ({ value: subset, label: subset })));
const variants = fonts.find((font) => font.family === typography.font.family)?.variants ?? [];
setVariants(variants.map((variant) => ({ value: variant, label: variant })));
}, [typography.font.family]);
return (
<section id="typography" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("typography")}
<h2 className="line-clamp-1 text-3xl font-bold">Typography</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="grid grid-cols-2 gap-4">
{fontSuggestions.map((font) => (
<Button
key={font}
variant="outline"
style={{ fontFamily: font }}
disabled={typography.font.family === font}
onClick={() => {
setValue("metadata.typography.font.family", font);
}}
className={cn(
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
typography.font.family === font && "ring-1",
)}
>
{font}
</Button>
))}
</div>
<div className="space-y-1.5">
<Label>Font Family</Label>
<Combobox
options={families}
value={typography.font.family}
searchPlaceholder="Search for a font family"
onValueChange={(value) => {
setValue("metadata.typography.font.family", value);
setValue("metadata.typography.font.subset", "latin");
setValue("metadata.typography.font.variants", ["regular"]);
}}
/>
</div>
<div className="grid grid-cols-2 gap-x-4">
<div className="space-y-1.5">
<Label>Font Subset</Label>
<Combobox
options={subsets}
value={typography.font.subset}
searchPlaceholder="Search for a font subset"
onValueChange={(value) => {
setValue("metadata.typography.font.subset", value);
}}
/>
</div>
<div className="space-y-1.5">
<Label>Font Variants</Label>
<Combobox
multiple
options={variants}
value={typography.font.variants}
searchPlaceholder="Search for a font variant"
onValueChange={(value) => {
setValue("metadata.typography.font.variants", value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Font Size</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={12}
max={18}
step={1}
value={[typography.font.size]}
onValueChange={(value) => {
setValue("metadata.typography.font.size", value[0]);
}}
/>
<span className="text-base font-bold">{typography.font.size}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Line Height</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={0}
max={3}
step={0.25}
value={[typography.lineHeight]}
onValueChange={(value) => {
setValue("metadata.typography.lineHeight", value[0]);
}}
/>
<span className="text-base font-bold">{typography.lineHeight}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Options</Label>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.typography.underlineLinks"
checked={typography.underlineLinks}
onCheckedChange={(checked) => {
setValue("metadata.typography.underlineLinks", checked);
}}
/>
<Label htmlFor="metadata.typography.underlineLinks">Underline Links</Label>
</div>
</div>
</div>
</main>
</section>
);
};