mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-25 22:21:26 +10:00
273 lines
7.6 KiB
TypeScript
273 lines
7.6 KiB
TypeScript
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 { t, Trans } from "@lingui/macro";
|
|
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">{t`Layout`}</h2>
|
|
</div>
|
|
|
|
<Tooltip content={t`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">
|
|
<Trans>Page {pageIndex + 1}</Trans>
|
|
</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={t`Main`} items={main} />
|
|
<Column id={sidebarIndex} name={t`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">{t`Add New Page`}</span>
|
|
</Button>
|
|
</main>
|
|
</section>
|
|
);
|
|
};
|