Files
Reactive-Resume/apps/web/src/routes/builder/$resumeId/route.tsx
T

186 lines
6.0 KiB
TypeScript

import type React from "react";
import type { Layout } from "react-resizable-panels";
import type { BuilderLayout } from "./-store/sidebar";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import Cookies from "js-cookie";
import { useEffect, useRef } from "react";
import { usePanelRef } from "react-resizable-panels";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import {
useBuilderResumeUpdateSubscription,
useInitializeResumeStore,
useMergeResumeMetadata,
useResumeCleanup,
useResumeStore,
} from "@/features/resume/builder/draft";
import { useIsMobile } from "@/hooks/use-mobile";
import { orpc } from "@/libs/orpc/client";
import { createNoindexFollowMeta } from "@/libs/seo";
import { BuilderHeader } from "./-components/header";
import { BuilderSidebarLeft } from "./-sidebar/left";
import { BuilderSidebarRight } from "./-sidebar/right";
import {
BUILDER_LAYOUT_COOKIE_NAME,
DEFAULT_BUILDER_LAYOUT,
mapPanelLayoutToBuilderLayout,
parseBuilderLayoutCookie,
useBuilderSidebar,
useBuilderSidebarStore,
} from "./-store/sidebar";
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
loader: async ({ params, context }) => {
const [layout, resume] = await Promise.all([
getBuilderLayout(),
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
]);
return { layout, name: resume.name };
},
head: ({ loaderData }) => ({
meta: loaderData
? [{ title: `${loaderData.name} - Reactive Resume` }, createNoindexFollowMeta()]
: [createNoindexFollowMeta()],
}),
});
function RouteComponent() {
const { layout: initialLayout } = Route.useLoaderData();
const { resumeId } = Route.useParams();
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
const initializeResumeStore = useInitializeResumeStore();
const mergeResumeMetadata = useMergeResumeMetadata();
const isReady = useResumeStore((state) => state.isReady);
const initializedResumeId = useResumeStore((state) => state.resumeId);
const isInitialized = isReady && initializedResumeId === resumeId;
useResumeCleanup();
useBuilderResumeUpdateSubscription();
useEffect(() => {
if (isInitialized) return;
initializeResumeStore(resume);
}, [initializeResumeStore, isInitialized, resume]);
useEffect(() => {
mergeResumeMetadata(resume);
}, [
mergeResumeMetadata,
resume.id,
resume.name,
resume.slug,
resume.tags,
resume.isLocked,
resume.isPublic,
resume.hasPassword,
resume.updatedAt,
resume,
]);
if (!isInitialized) return null;
return <BuilderLayoutShell initialLayout={initialLayout} />;
}
type BuilderLayoutShellProps = React.ComponentProps<"div"> & {
initialLayout: BuilderLayout;
};
function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
const isMobile = useIsMobile();
const canPersistLayoutRef = useRef(false);
const leftSidebarRef = usePanelRef();
const rightSidebarRef = usePanelRef();
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
maxSidebarSize: state.maxSidebarSize,
collapsedSidebarSize: state.collapsedSidebarSize,
}));
useEffect(() => {
setLayout(initialLayout);
canPersistLayoutRef.current = true;
}, [initialLayout, setLayout]);
const onLayoutChanged = (layout: Layout) => {
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
if (!canPersistLayoutRef.current) return;
setLayout(nextLayout);
setBuilderLayout(nextLayout);
};
useEffect(() => {
if (!leftSidebarRef || !rightSidebarRef) return;
setLeftSidebar(leftSidebarRef);
setRightSidebar(rightSidebarRef);
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
const sidebarMinSize = isMobile ? "0%" : `${collapsedSidebarSize * 2}px`;
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
const leftSidebarSize = isMobile ? "0%" : `${initialLayout.left}%`;
const rightSidebarSize = isMobile ? "0%" : `${initialLayout.right}%`;
const artboardSize = isMobile ? "100%" : `${initialLayout.artboard}%`;
return (
<div className="flex h-svh flex-col">
<BuilderHeader />
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
<ResizablePanel
collapsible
id="left"
panelRef={leftSidebarRef}
maxSize={maxSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={leftSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
<BuilderSidebarLeft />
</ResizablePanel>
<ResizableSeparator withHandle className="z-50 border-s" />
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
<Outlet />
</ResizablePanel>
<ResizableSeparator withHandle className="z-50 border-e" />
<ResizablePanel
collapsible
id="right"
panelRef={rightSidebarRef}
maxSize={maxSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={rightSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
<BuilderSidebarRight />
</ResizablePanel>
</ResizableGroup>
</div>
);
}
const setBuilderLayout = (data: BuilderLayout) => {
const layout = parseBuilderLayoutCookie(JSON.stringify(data));
Cookies.set(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(layout), { path: "/" });
};
const getBuilderLayout = (): BuilderLayout => {
const layout = Cookies.get(BUILDER_LAYOUT_COOKIE_NAME);
if (!layout) return DEFAULT_BUILDER_LAYOUT;
return parseBuilderLayoutCookie(layout);
};