mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-23 16:33:43 +10:00
- Use browserless over gotenberg
- Implement functionality to move items between sections or pages - Enhance custom sections to have a `type` property - Update the v4 importer to account for custom sections - Update healthcheck to be a simple curl command - Update dependencies to latest and a lot more changes
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/integrations/drizzle/client";
|
||||
import { printerService } from "@/integrations/orpc/services/printer";
|
||||
import { getStorageService } from "@/integrations/orpc/services/storage";
|
||||
|
||||
function isUnhealthy(check: unknown): boolean {
|
||||
@@ -20,6 +21,7 @@ async function handler() {
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database: await checkDatabase(),
|
||||
printer: await checkPrinter(),
|
||||
storage: await checkStorage(),
|
||||
};
|
||||
|
||||
@@ -50,6 +52,19 @@ async function checkDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPrinter() {
|
||||
try {
|
||||
const result = await printerService.healthcheck();
|
||||
|
||||
return { status: "healthy", ...result };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStorage() {
|
||||
try {
|
||||
const storageService = getStorageService();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowsClockwiseIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FileJsIcon,
|
||||
FilePdfIcon,
|
||||
type Icon,
|
||||
LinkSimpleIcon,
|
||||
@@ -20,7 +21,7 @@ import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl, generateFilename } from "@/utils/file";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/file";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
export function BuilderDock() {
|
||||
@@ -49,6 +50,15 @@ export function BuilderDock() {
|
||||
toast.success(t`A link to your resume has been copied to clipboard.`);
|
||||
}, [publicUrl, copyToClipboard]);
|
||||
|
||||
const onDownloadJSON = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const jsonString = JSON.stringify(resume, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
const filename = generateFilename(resume.data.basics.name, "json");
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
|
||||
@@ -73,6 +83,7 @@ export function BuilderDock() {
|
||||
<DockIcon icon={CubeFocusIcon} title={t`Center view`} onClick={() => centerView()} />
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
|
||||
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
|
||||
<DockIcon
|
||||
title={t`Download PDF`}
|
||||
disabled={isPrinting}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
PencilSimpleLineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { match } from "ts-pattern";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -24,47 +24,144 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/animate-ui/components/radix/dropdown-menu";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import type { CustomSection } from "@/schema/resume/data";
|
||||
import { sanitizeHtml } from "@/utils/sanitize";
|
||||
import type { CustomSection, CustomSectionItem as CustomSectionItemType, SectionType } from "@/schema/resume/data";
|
||||
import { getSectionTitle } from "@/utils/resume/section";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton } from "../shared/section-item";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
function getItemTitle(type: SectionType, item: CustomSectionItemType): string {
|
||||
return match(type)
|
||||
.with("profiles", () => ("network" in item ? item.network : ""))
|
||||
.with("experience", () => ("company" in item ? item.company : ""))
|
||||
.with("education", () => ("school" in item ? item.school : ""))
|
||||
.with("projects", () => ("name" in item ? item.name : ""))
|
||||
.with("skills", () => ("name" in item ? item.name : ""))
|
||||
.with("languages", () => ("language" in item ? item.language : ""))
|
||||
.with("interests", () => ("name" in item ? item.name : ""))
|
||||
.with("awards", () => ("title" in item ? item.title : ""))
|
||||
.with("certifications", () => ("title" in item ? item.title : ""))
|
||||
.with("publications", () => ("title" in item ? item.title : ""))
|
||||
.with("volunteer", () => ("organization" in item ? item.organization : ""))
|
||||
.with("references", () => ("name" in item ? item.name : ""))
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function getItemSubtitle(type: SectionType, item: CustomSectionItemType): string | undefined {
|
||||
return match(type)
|
||||
.with("profiles", () => ("username" in item ? item.username : undefined))
|
||||
.with("experience", () => ("position" in item ? item.position : undefined))
|
||||
.with("education", () => ("degree" in item ? item.degree : undefined))
|
||||
.with("projects", () => ("period" in item ? item.period : undefined))
|
||||
.with("skills", () => ("proficiency" in item ? item.proficiency : undefined))
|
||||
.with("languages", () => ("fluency" in item ? item.fluency : undefined))
|
||||
.with("interests", () => undefined)
|
||||
.with("awards", () => ("awarder" in item ? item.awarder : undefined))
|
||||
.with("certifications", () => ("issuer" in item ? item.issuer : undefined))
|
||||
.with("publications", () => ("publisher" in item ? item.publisher : undefined))
|
||||
.with("volunteer", () => ("period" in item ? item.period : undefined))
|
||||
.with("references", () => undefined)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function CustomSectionBuilder() {
|
||||
const customSections = useResumeStore((state) => state.resume.data.customSections);
|
||||
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("rounded-md border", customSections.length === 0 && "border-dashed")}>
|
||||
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
|
||||
<AnimatePresence>
|
||||
{customSections.map((section) => (
|
||||
<CustomSectionItem key={section.id} section={section} />
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<SectionAddItemButton type="custom">
|
||||
{/* Add Custom Section Button */}
|
||||
<SectionAddItemButton type="custom" variant="outline" className="rounded-md">
|
||||
<Trans>Add a new custom section</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionItem({ section }: { section: CustomSection }) {
|
||||
const confirm = useConfirm();
|
||||
function CustomSectionContainer({ section }: { section: CustomSection }) {
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const sanitizedContent = useMemo(() => sanitizeHtml(section.content), [section.content]);
|
||||
|
||||
const onUpdate = () => {
|
||||
const onUpdateSection = () => {
|
||||
openDialog("resume.sections.custom.update", section);
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
openDialog("resume.sections.custom.create", section);
|
||||
const handleReorder = (items: CustomSectionItemType[]) => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
draft.customSections[sectionIndex].items = items;
|
||||
});
|
||||
};
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
{/* Section Header */}
|
||||
<div className="group flex select-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdateSection}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-left transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
|
||||
section.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Badge variant="secondary" className="mb-1.5 rounded-sm">
|
||||
{getSectionTitle(section.type)}
|
||||
</Badge>
|
||||
<span className="line-clamp-1 break-all font-medium text-base">{section.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Plural value={section.items.length} one="# item" other="# items" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<CustomSectionDropdownMenu section={section} />
|
||||
</div>
|
||||
|
||||
{/* Section Items */}
|
||||
{section.items.length > 0 && (
|
||||
<div className={cn("border-t", section.hidden && "opacity-50")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type={section.type}
|
||||
item={item}
|
||||
customSectionId={section.id}
|
||||
title={getItemTitle(section.type, item)}
|
||||
subtitle={getItemSubtitle(section.type, item)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Item Button */}
|
||||
<div className="border-t">
|
||||
<SectionAddItemButton type={section.type} customSectionId={section.id}>
|
||||
<Trans>Add a new item</Trans>
|
||||
</SectionAddItemButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionDropdownMenu({ section }: { section: CustomSection }) {
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const onToggleSectionVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
@@ -72,6 +169,14 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdateSection = () => {
|
||||
openDialog("resume.sections.custom.update", section);
|
||||
};
|
||||
|
||||
const onDuplicateSection = () => {
|
||||
openDialog("resume.sections.custom.create", section);
|
||||
};
|
||||
|
||||
const onSetColumns = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
@@ -80,7 +185,7 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
const onDeleteSection = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to delete this custom section?", {
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
@@ -90,7 +195,6 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
|
||||
|
||||
updateResumeData((draft) => {
|
||||
draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id);
|
||||
// remove from layout
|
||||
draft.metadata.layout.pages = draft.metadata.layout.pages.map((page) => ({
|
||||
...page,
|
||||
main: page.main.filter((id) => id !== section.id),
|
||||
@@ -100,84 +204,60 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={section.id}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="group flex select-none border-b"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdate}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-left transition-opacity hover:bg-secondary/20 focus:outline-none focus-visible:ring-1",
|
||||
section.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="line-clamp-1 font-medium text-base">{section.title}</div>
|
||||
<div
|
||||
className="line-clamp-2 text-muted-foreground text-xs"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
/>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100"
|
||||
>
|
||||
<DotsThreeVerticalIcon />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 focus:outline-none focus-visible:ring-1 group-hover:opacity-100"
|
||||
>
|
||||
<DotsThreeVerticalIcon />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={onToggleSectionVisibility}>
|
||||
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={onToggleVisibility}>
|
||||
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onUpdateSection}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onDuplicateSection}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ColumnsIcon />
|
||||
<Trans>Columns</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ColumnsIcon />
|
||||
<Trans>Columns</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
|
||||
{[1, 2, 3, 4, 5, 6].map((column) => (
|
||||
<DropdownMenuRadioItem key={column} value={column.toString()}>
|
||||
<Plural value={column} one="# Column" other="# Columns" />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
|
||||
{[1, 2, 3, 4, 5, 6].map((column) => (
|
||||
<DropdownMenuRadioItem key={column} value={column.toString()}>
|
||||
<Plural value={column} one="# Column" other="# Columns" />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</motion.div>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onSelect={onDeleteSection}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,167 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowBendUpRightIcon,
|
||||
CopySimpleIcon,
|
||||
DotsSixVerticalIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
FolderPlusIcon,
|
||||
PencilSimpleLineIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { Button, type ButtonProps } from "@/components/animate-ui/components/buttons/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/animate-ui/components/radix/dropdown-menu";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import type { SectionItem as SectionItemType, SectionType } from "@/schema/resume/data";
|
||||
import {
|
||||
addItemToSection,
|
||||
createCustomSectionWithItem,
|
||||
createPageWithSection,
|
||||
getCompatibleMoveTargets,
|
||||
getSourceSectionTitle,
|
||||
removeItemFromSource,
|
||||
} from "@/utils/resume/move-item";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
// ============================================================================
|
||||
// MoveItemSubmenu Component
|
||||
// ============================================================================
|
||||
|
||||
type MoveItemSubmenuProps = {
|
||||
type: SectionType;
|
||||
item: SectionItemType;
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Submenu component for moving items between sections/pages.
|
||||
* Displays compatible targets grouped by page with options to:
|
||||
* - Move to existing compatible section
|
||||
* - Create new section on existing page
|
||||
* - Create new page with new section
|
||||
*/
|
||||
function MoveItemSubmenu({ type, item, customSectionId }: MoveItemSubmenuProps) {
|
||||
const resume = useResumeStore((state) => state.resume);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
/** Compute compatible move targets grouped by page */
|
||||
const moveTargets = useMemo(
|
||||
() => getCompatibleMoveTargets(resume.data, type, customSectionId),
|
||||
[resume.data, type, customSectionId],
|
||||
);
|
||||
|
||||
/** Get the current section's title (used when creating new sections) */
|
||||
const currentSectionTitle = useMemo(
|
||||
() => getSourceSectionTitle(resume.data, type, customSectionId),
|
||||
[resume.data, type, customSectionId],
|
||||
);
|
||||
|
||||
/** Handler: Move item to an existing section */
|
||||
const handleMoveToSection = (targetSectionId: string) => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
addItemToSection(draft, removedItem, targetSectionId, type);
|
||||
});
|
||||
};
|
||||
|
||||
/** Handler: Create a new custom section on an existing page and move the item there */
|
||||
const handleNewSectionOnPage = (pageIndex: number) => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
createCustomSectionWithItem(draft, removedItem, type, currentSectionTitle, pageIndex);
|
||||
});
|
||||
};
|
||||
|
||||
/** Handler: Create a new page with a new custom section and move the item there */
|
||||
const handleNewPage = () => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
createPageWithSection(draft, removedItem, type, currentSectionTitle);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ArrowBendUpRightIcon />
|
||||
<Trans>Move to</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
{/* Render each page as a submenu */}
|
||||
{moveTargets.map(({ pageIndex, sections }) => (
|
||||
<DropdownMenuSub key={pageIndex}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FileIcon />
|
||||
<Trans>Page {pageIndex + 1}</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
{/* Existing compatible sections on this page */}
|
||||
{sections.map(({ sectionId, sectionTitle }) => (
|
||||
<DropdownMenuItem key={sectionId} onSelect={() => handleMoveToSection(sectionId)}>
|
||||
{sectionTitle}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{/* Separator if there are existing sections */}
|
||||
{sections.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{/* Option to create a new section on this page */}
|
||||
<DropdownMenuItem onSelect={() => handleNewSectionOnPage(pageIndex)}>
|
||||
<FolderPlusIcon />
|
||||
<Trans>New Section</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Option to create a new page with a new section */}
|
||||
<DropdownMenuItem onSelect={handleNewPage}>
|
||||
<PlusCircleIcon />
|
||||
<Trans>New Page</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SectionItem Component
|
||||
// ============================================================================
|
||||
|
||||
type Props<T extends SectionItemType> = {
|
||||
type: SectionType;
|
||||
item: T;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
export function SectionItem<T extends SectionItemType>({ type, item, title, subtitle }: Props<T>) {
|
||||
export function SectionItem<T extends SectionItemType>({ type, item, title, subtitle, customSectionId }: Props<T>) {
|
||||
const confirm = useConfirm();
|
||||
const controls = useDragControls();
|
||||
const { openDialog } = useDialogStore();
|
||||
@@ -39,20 +169,30 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
const section = draft.sections[type];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
} else {
|
||||
const section = draft.sections[type];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdate = () => {
|
||||
openDialog(`resume.sections.${type}.update`, item);
|
||||
// Type assertion needed because TypeScript can't narrow the union type through template literals
|
||||
openDialog(`resume.sections.${type}.update`, { item, customSectionId } as never);
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
openDialog(`resume.sections.${type}.create`, item);
|
||||
// Type assertion needed because TypeScript can't narrow the union type through template literals
|
||||
openDialog(`resume.sections.${type}.create`, { item, customSectionId } as never);
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
@@ -64,11 +204,19 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const section = draft.sections[type];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
} else {
|
||||
const section = draft.sections[type];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -84,7 +232,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
|
||||
className="group relative flex h-18 select-none border-b"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 group-hover:opacity-100"
|
||||
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 group-hover:opacity-100"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
@@ -96,7 +244,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
|
||||
<button
|
||||
onClick={onUpdate}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 pl-1.5 text-left opacity-100 transition-opacity hover:bg-secondary/20 focus:outline-none focus-visible:ring-1",
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 pl-1.5 text-left opacity-100 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
|
||||
item.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -106,7 +254,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
|
||||
<button className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
|
||||
<DotsThreeVerticalIcon />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -131,6 +279,8 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<MoveItemSubmenu type={type} item={item} customSectionId={customSectionId} />
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
@@ -147,26 +297,32 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
|
||||
);
|
||||
}
|
||||
|
||||
type AddButtonProps = {
|
||||
type AddButtonProps = Omit<ButtonProps, "type"> & {
|
||||
type: SectionType | "custom";
|
||||
children: React.ReactNode;
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
export function SectionAddItemButton({ type, children }: AddButtonProps) {
|
||||
export function SectionAddItemButton({ type, customSectionId, className, children, ...props }: AddButtonProps) {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const handleAdd = () => {
|
||||
openDialog(`resume.sections.${type}.create`, undefined);
|
||||
if (type === "custom") {
|
||||
openDialog("resume.sections.custom.create", undefined);
|
||||
} else {
|
||||
openDialog(`resume.sections.${type}.create`, customSectionId ? { customSectionId } : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
tapScale={1}
|
||||
variant="ghost"
|
||||
onClick={handleAdd}
|
||||
className="flex w-full items-center gap-x-2 px-3 py-4 font-medium hover:bg-secondary/20 focus:outline-none focus-visible:ring-1"
|
||||
className={cn("h-12 w-full justify-start rounded-t-none", className)}
|
||||
{...props}
|
||||
>
|
||||
<PlusIcon />
|
||||
{children}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ function LayoutColumn({ pageIndex, columnId, items, disabled = false }: LayoutCo
|
||||
|
||||
return (
|
||||
<SortableContext id={droppableId} items={items} strategy={verticalListSortingStrategy}>
|
||||
<div className={cn(disabled && "opacity-50")}>
|
||||
<div className={cn("space-y-1.5", disabled && "opacity-50")}>
|
||||
<div className="@md:row-start-1 pl-4 font-medium text-xs">{getColumnLabel(columnId)}</div>
|
||||
|
||||
<div
|
||||
@@ -355,7 +355,7 @@ const LayoutItemContent = forwardRef<HTMLDivElement, LayoutItemContentProps>(
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={cn(
|
||||
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
|
||||
"hover:bg-secondary/20 active:cursor-grabbing active:border-primary/60 active:bg-secondary/20",
|
||||
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
|
||||
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
|
||||
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
|
||||
className,
|
||||
|
||||
Reference in New Issue
Block a user