mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-24 21:51:34 +10:00
refactor(v4.0.0-alpha): beginning of a new era
This commit is contained in:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
269
apps/client/src/pages/builder/sidebars/right/sections/layout.tsx
Normal file
269
apps/client/src/pages/builder/sidebars/right/sections/layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
133
apps/client/src/pages/builder/sidebars/right/sections/theme.tsx
Normal file
133
apps/client/src/pages/builder/sidebars/right/sections/theme.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user