updated(builder-page): added collapsible feature for each section

This commit is contained in:
abhas20
2025-10-24 18:38:52 +05:30
parent 6fcb7a4845
commit 4c0cc947a2
3 changed files with 162 additions and 88 deletions

View File

@ -15,7 +15,7 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
import { PlusIcon } from "@phosphor-icons/react"; import { CaretDownIcon, CaretRightIcon, PlusIcon } from "@phosphor-icons/react";
import type { SectionItem, SectionKey, SectionWithItem } from "@reactive-resume/schema"; import type { SectionItem, SectionKey, SectionWithItem } from "@reactive-resume/schema";
import { Button } from "@reactive-resume/ui"; import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils"; import { cn } from "@reactive-resume/utils";
@ -38,10 +38,13 @@ type Props<T extends SectionItem> = {
export const SectionBase = <T extends SectionItem>({ id, title, description }: Props<T>) => { export const SectionBase = <T extends SectionItem>({ id, title, description }: Props<T>) => {
const { open } = useDialog(id); const { open } = useDialog(id);
const collapsed = useResumeStore((state) => state.collapsedSections[id] ?? false);
const toggleSectionCollapse = useResumeStore((state) => state.toggleSectionCollapsed);
const setValue = useResumeStore((state) => state.setValue); const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore((state) => const section = useResumeStore(
get(state.resume.data.sections, id), (state) => get(state.resume.data.sections, id) as SectionWithItem<T>,
) as SectionWithItem<T>; );
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@ -97,7 +100,17 @@ export const SectionBase = <T extends SectionItem>({ id, title, description }: P
className="grid gap-y-6" className="grid gap-y-6"
> >
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-2">
<button
className="text-gray-500 transition-colors hover:text-gray-700"
aria-label={collapsed ? t`Expand section` : t`Collapse section`}
onClick={() => {
toggleSectionCollapse(id);
}}
>
{collapsed ? <CaretRightIcon size={18} /> : <CaretDownIcon size={18} />}
</button>
<SectionIcon id={id} size={18} /> <SectionIcon id={id} size={18} />
<h2 className="line-clamp-1 text-2xl font-bold lg:text-3xl">{section.name}</h2> <h2 className="line-clamp-1 text-2xl font-bold lg:text-3xl">{section.name}</h2>
</div> </div>
@ -107,74 +120,91 @@ export const SectionBase = <T extends SectionItem>({ id, title, description }: P
</div> </div>
</header> </header>
<main className={cn("grid transition-opacity", !section.visible && "opacity-50")}> <AnimatePresence initial={false}>
{section.items.length === 0 && ( {!collapsed && (
<Button <motion.div
variant="outline" key="content"
className="gap-x-2 border-dashed py-6 leading-relaxed hover:bg-secondary-accent" initial={{ height: 0, opacity: 0 }}
onClick={onCreate} animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
> >
<PlusIcon size={14} /> <main className={cn("grid transition-opacity", !section.visible && "opacity-50")}>
<span className="font-medium"> {section.items.length === 0 && (
{t({ <Button
message: "Add a new item", className="gap-x-2 border-dashed py-6 leading-relaxed hover:bg-secondary-accent"
context: "For example, add a new work experience, or add a new profile.", variant="outline"
})} onClick={() => {
</span> onCreate();
</Button> }}
>
<PlusIcon size={14} />
<span className="font-medium">
{t({
message: "Add a new item",
context: "For example, add a new work experience, or add a new profile.",
})}
</span>
</Button>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToParentElement]}
onDragEnd={onDragEnd}
>
<SortableContext items={section.items} strategy={verticalListSortingStrategy}>
<AnimatePresence>
{section.items.map((item, index) => (
<SectionListItem
key={item.id}
id={item.id}
visible={item.visible}
title={title(item as T)}
description={description?.(item as T)}
onUpdate={() => {
onUpdate(item as T);
}}
onDelete={() => {
onDelete(item as T);
}}
onDuplicate={() => {
onDuplicate(item as T);
}}
onToggleVisibility={() => {
onToggleVisibility(index);
}}
/>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
</main>
{section.items.length > 0 && (
<footer className="flex items-center justify-end">
<Button
className="ml-auto gap-x-2 text-xs lg:text-sm"
variant="outline"
onClick={() => {
onCreate();
}}
>
<PlusIcon />
<span>
{t({
message: "Add a new item",
context: "For example, add a new work experience, or add a new profile.",
})}
</span>
</Button>
</footer>
)}
</motion.div>
)} )}
</AnimatePresence>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToParentElement]}
onDragEnd={onDragEnd}
>
<SortableContext items={section.items} strategy={verticalListSortingStrategy}>
<AnimatePresence>
{section.items.map((item, index) => (
<SectionListItem
key={item.id}
id={item.id}
visible={item.visible}
title={title(item as T)}
description={description?.(item as T)}
onUpdate={() => {
onUpdate(item as T);
}}
onDelete={() => {
onDelete(item as T);
}}
onDuplicate={() => {
onDuplicate(item as T);
}}
onToggleVisibility={() => {
onToggleVisibility(index);
}}
/>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
</main>
{section.items.length > 0 && (
<footer className="flex items-center justify-end">
<Button
variant="outline"
className="ml-auto gap-x-2 text-xs lg:text-sm"
onClick={onCreate}
>
<PlusIcon />
<span>
{t({
message: "Add a new item",
context: "For example, add a new work experience, or add a new profile.",
})}
</span>
</Button>
</footer>
)}
</motion.section> </motion.section>
); );
}; };

View File

@ -1,6 +1,9 @@
import { t } from "@lingui/macro";
import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react";
import { defaultSections } from "@reactive-resume/schema"; import { defaultSections } from "@reactive-resume/schema";
import { RichInput } from "@reactive-resume/ui"; import { RichInput } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils"; import { cn } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { AiActions } from "@/client/components/ai-actions"; import { AiActions } from "@/client/components/ai-actions";
import { useResumeStore } from "@/client/stores/resume"; import { useResumeStore } from "@/client/stores/resume";
@ -14,11 +17,23 @@ export const SummarySection = () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(state) => state.resume.data.sections.summary ?? defaultSections.summary, (state) => state.resume.data.sections.summary ?? defaultSections.summary,
); );
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const collapsed = useResumeStore((state) => state.collapsedSections.summary ?? false);
const toogleSectionCollapse = useResumeStore((state) => state.toggleSectionCollapsed);
return ( return (
<section id="summary" className="grid gap-y-6"> <section id="summary" className="grid gap-y-6">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<button
className="text-gray-500 transition-colors hover:text-gray-700"
aria-label={collapsed ? t`Expand section` : t`Collapse section`}
onClick={() => {
toogleSectionCollapse("summary");
}}
>
{collapsed ? <CaretRightIcon size={18} /> : <CaretDownIcon size={18} />}
</button>
<SectionIcon id="summary" size={18} /> <SectionIcon id="summary" size={18} />
<h2 className="line-clamp-1 text-2xl font-bold lg:text-3xl">{section.name}</h2> <h2 className="line-clamp-1 text-2xl font-bold lg:text-3xl">{section.name}</h2>
</div> </div>
@ -28,23 +43,35 @@ export const SummarySection = () => {
</div> </div>
</header> </header>
<main className={cn(!section.visible && "opacity-50")}> <AnimatePresence>
<RichInput {!collapsed && (
content={section.content} <motion.div
footer={(editor) => ( key="summary-content"
<AiActions initial={{ opacity: 0, height: 0 }}
value={editor.getText()} animate={{ opacity: 1, height: "auto" }}
onChange={(value) => { exit={{ opacity: 0, height: 0 }}
editor.commands.setContent(value, true); transition={{ duration: 0.2 }}
setValue("sections.summary.content", value); >
}} <main className={cn(!section.visible && "opacity-50")}>
/> <RichInput
)} content={section.content}
onChange={(value) => { footer={(editor) => (
setValue("sections.summary.content", value); <AiActions
}} value={editor.getText()}
/> onChange={(value) => {
</main> editor.commands.setContent(value, true);
setValue("sections.summary.content", value);
}}
/>
)}
onChange={(value) => {
setValue("sections.summary.content", value);
}}
/>
</main>
</motion.div>
)}
</AnimatePresence>
</section> </section>
); );
}; };

View File

@ -23,6 +23,10 @@ type ResumeStore = {
// Custom Section Actions // Custom Section Actions
addSection: () => void; addSection: () => void;
removeSection: (sectionId: SectionKey) => void; removeSection: (sectionId: SectionKey) => void;
collapsedSections: Record<string, boolean>;
toggleSectionCollapsed: (id: string) => void;
setSectionCollapsed: (id: string, collapsed: boolean) => void;
}; };
export const useResumeStore = create<ResumeStore>()( export const useResumeStore = create<ResumeStore>()(
@ -69,6 +73,19 @@ export const useResumeStore = create<ResumeStore>()(
}); });
} }
}, },
collapsedSections: {},
toggleSectionCollapsed: (id) => {
set((state) => {
state.collapsedSections[id] = !state.collapsedSections[id];
});
},
setSectionCollapsed: (id, collapsed) => {
set((state) => {
state.collapsedSections[id] = collapsed;
});
},
})), })),
{ {
limit: 100, limit: 100,