diff --git a/bun.lockb b/bun.lockb index de7bb1b..2815c07 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 31c1d87..6aae44e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "sonner": "^1.5.0", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", + "vaul": "^0.9.1", "zod": "^3.23.8", "zustand": "^4.5.4" }, diff --git a/src/app/(main)/i/[subdomain]/page.tsx b/src/app/(main)/i/[subdomain]/page.tsx index 60fac29..e52aed3 100644 --- a/src/app/(main)/i/[subdomain]/page.tsx +++ b/src/app/(main)/i/[subdomain]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata, ResolvingMetadata } from "next"; import { notFound } from "next/navigation"; import Recursive from "~/components/editor/elements/recursive"; +import FloatingActionButton from "~/components/editor/fab"; import EditorProvider from "~/components/editor/provider"; import { getInvitationByEventUrl } from "~/lib/db/schema/invitations.query"; @@ -45,10 +46,13 @@ export default async function Page({ params }: Props) { editorData={invitation.customFields} editorState={{ isPreviewMode: true }} > -
+
{invitation.customFields.elements.map((childElement) => ( ))} + {invitation.customFields?.fab?.type === "invitation_response" && ( + + )}
); diff --git a/src/components/editor/action.ts b/src/components/editor/action.ts index 13b8b22..7368156 100644 --- a/src/components/editor/action.ts +++ b/src/components/editor/action.ts @@ -1,3 +1,4 @@ +import { nanoid } from "nanoid"; import { emptyElement } from "~/components/editor/constant"; import type { DeviceType, @@ -35,6 +36,9 @@ type EditorActionMap = { DELETE_ELEMENT: { elementDetails: EditorElement; }; + DUPLICATE_ELEMENT: { + elementDetails: EditorElement; + }; CHANGE_CLICKED_ELEMENT: { elementDetails?: EditorElement; }; @@ -136,6 +140,34 @@ const findElementAndParent = ( return [null, null, -1]; }; +const findParentId = ( + elements: EditorElement[], + targetId: string, +): string | null => { + for (const el of elements) { + if (Array.isArray(el.content)) { + if (el.content.some((child) => child.id === targetId)) { + return el.id; + } + const foundInChild = findParentId(el.content, targetId); + if (foundInChild) return foundInChild; + } + } + return null; +}; + +const deepCloneElement = (element: EditorElement): EditorElement => { + const newElement = { ...element, id: nanoid() }; + + if (Array.isArray(element.content)) { + newElement.content = element.content.map((child) => + deepCloneElement(child), + ); + } + + return newElement; +}; + const removeElementFromParent = ( elements: EditorElement[], elementId: string, @@ -179,6 +211,8 @@ const actionHandlers: { ) => Editor; } = { ADD_ELEMENT: (editor, payload) => { + const newElement = payload.elementDetails; + const elements = traverseElements(editor.data.elements, (element) => { if ( element.id === payload.containerId && @@ -186,13 +220,13 @@ const actionHandlers: { ) { return { ...element, - content: [...element.content, payload.elementDetails], + content: [...element.content, newElement], } as EditorElement; } return element; }); - return updateEditorHistory(editor, { elements }); + return updateEditorHistory(editor, { elements }, newElement); }, MOVE_ELEMENT: (editor, payload) => { @@ -309,6 +343,24 @@ const actionHandlers: { return updateEditorHistory(editor, { elements }); }, + DUPLICATE_ELEMENT: (editor, payload) => { + const containerId = findParentId( + editor.data.elements, + payload.elementDetails.id, + ); + + if (!containerId) { + return editor; + } + + const newElement = deepCloneElement(payload.elementDetails); + + return actionHandlers.ADD_ELEMENT(editor, { + containerId, + elementDetails: newElement, + }); + }, + CHANGE_CLICKED_ELEMENT: (editor, payload) => { const isValidSelect = isValidSelectEditorElement(payload.elementDetails); diff --git a/src/components/editor/constant.ts b/src/components/editor/constant.ts index f4cccf8..899b769 100644 --- a/src/components/editor/constant.ts +++ b/src/components/editor/constant.ts @@ -37,6 +37,7 @@ export const kakaoMapDefaultStyles: React.CSSProperties = { export const editorTabValue = { ELEMENTS: "Elements", SETTINGS: "Settings", + INVITATION_RESPONSE: "Invitation Response", ELEMENT_SETTINGS: "Element Settings", } as const; @@ -61,6 +62,9 @@ export const bodyElement = { export const initialEditorData: EditorData = { elements: [bodyElement], + fab: { + type: "invitation_response", + }, }; export const initialEditorState: EditorState = { diff --git a/src/components/editor/elements/element-helper.tsx b/src/components/editor/elements/element-helper.tsx index c5736e0..7addb7c 100644 --- a/src/components/editor/elements/element-helper.tsx +++ b/src/components/editor/elements/element-helper.tsx @@ -92,6 +92,16 @@ export default function ElementHelper() { }); }; + const handleDuplicateElement = (e: React.MouseEvent) => { + e.stopPropagation(); + dispatch({ + type: "DUPLICATE_ELEMENT", + payload: { + elementDetails: element, + }, + }); + }; + return ( typeof window !== "undefined" && createPortal( @@ -144,7 +154,7 @@ export default function ElementHelper() { - + diff --git a/src/components/editor/fab/index.tsx b/src/components/editor/fab/index.tsx new file mode 100644 index 0000000..ba27a03 --- /dev/null +++ b/src/components/editor/fab/index.tsx @@ -0,0 +1,15 @@ +"use client"; + +import BottomSheet from "~/app/(playground)/pg/bottom-sheet/_components/bottom-sheet"; +import { useEditor } from "~/components/editor/provider"; + +export default function FloatingActionButton() { + const { editor } = useEditor(); + + switch (editor.data.fab.type) { + case "invitation_response": + return ; + default: + return null; + } +} diff --git a/src/components/editor/fab/invitation-response-fab.tsx b/src/components/editor/fab/invitation-response-fab.tsx new file mode 100644 index 0000000..232616b --- /dev/null +++ b/src/components/editor/fab/invitation-response-fab.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useForm } from "@tanstack/react-form"; +import { toast } from "sonner"; +import { Drawer } from "vaul"; +import { SheetHeader, SheetTitle } from "~/components/ui/sheet"; + +export default function InvitationResponseFab() { + return ( + +
+ + 세션 참여 조사하기 + +
+ + + + + + + +
+ ); +} + +function InvitationResponseForm() { + const form = useForm({ + defaultValues: { + name: "", + attendance: "", + }, + onSubmit: async ({ value }) => { + const { name, attendance } = value; + console.log(name, attendance); + // await createInvitationResponses(name, attendance as unknown as boolean); + toast("참여가 완료되었습니다.", { duration: 2000 }); + }, + }); + + return ( +
+ + 세션 참여 조사 + +
+ ); +} diff --git a/src/components/editor/main.tsx b/src/components/editor/main.tsx index 0a029c3..c0fb560 100644 --- a/src/components/editor/main.tsx +++ b/src/components/editor/main.tsx @@ -3,6 +3,7 @@ import { EyeOff } from "lucide-react"; import ElementHelper from "~/components/editor/elements/element-helper"; import Recursive from "~/components/editor/elements/recursive"; +import FloatingActionButton from "~/components/editor/fab"; import { useEditor } from "~/components/editor/provider"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -24,7 +25,7 @@ export default function EditorMain() { return (
( ))} +
diff --git a/src/components/editor/provider.tsx b/src/components/editor/provider.tsx index 95c1cdb..8bf4185 100644 --- a/src/components/editor/provider.tsx +++ b/src/components/editor/provider.tsx @@ -21,7 +21,7 @@ export const EditorContext = createContext<{ }); export type EditorProps = { - editorData?: EditorData; + editorData?: Partial; editorConfig?: Partial; editorState?: Partial; }; @@ -38,7 +38,10 @@ export default function EditorProvider({ }: EditorProviderProps) { const [editor, dispatch] = useReducer(editorReducer, { ...initialEditor, - data: editorData ?? initialEditor.data, + data: { + ...initialEditor.data, + ...editorData, + }, config: { ...initialEditorConfig, ...editorConfig, diff --git a/src/components/editor/sidebar/index.tsx b/src/components/editor/sidebar/index.tsx index 7f6908a..b4c786b 100644 --- a/src/components/editor/sidebar/index.tsx +++ b/src/components/editor/sidebar/index.tsx @@ -1,11 +1,12 @@ "use client"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"; -import { PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react"; +import { MailIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react"; import { editorTabValue } from "~/components/editor/constant"; import { useEditor } from "~/components/editor/provider"; import SidebarElementSettingsTab from "~/components/editor/sidebar/sidebar-element-settings-tab"; import SidebarElementsTab from "~/components/editor/sidebar/sidebar-elements-tab"; +import SidebarInvitationResponseTab from "~/components/editor/sidebar/sidebar-invitation-response-tab"; import SidebarSettingsTab from "~/components/editor/sidebar/sidebar-settings-tab"; import type { EditorTabTypeValue } from "~/components/editor/type"; import { isValidSelectEditorElement } from "~/components/editor/util"; @@ -29,6 +30,11 @@ export default function EditorSidebar() { icon: , content: , }, + { + value: editorTabValue.INVITATION_RESPONSE, + icon: , + content: , + }, { value: editorTabValue.ELEMENT_SETTINGS, icon: , diff --git a/src/components/editor/sidebar/sidebar-invitation-response-tab.tsx b/src/components/editor/sidebar/sidebar-invitation-response-tab.tsx new file mode 100644 index 0000000..23231fe --- /dev/null +++ b/src/components/editor/sidebar/sidebar-invitation-response-tab.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEditor } from "~/components/editor/provider"; +import { + SheetDescription, + SheetHeader, + SheetTitle, +} from "~/components/ui/sheet"; +import { Switch } from "~/components/ui/switch"; + +export default function SidebarInvitationResponseTab() { + const { editor, dispatch } = useEditor(); + + return ( +
+ +
+ 초대 응답 설정 + +
+ + 이벤트에 대한 초대 응답 설정을 관리합니다. + +
+ {editor.config && } +
+ ); +} + +function InvitationResponseContent() { + return ( +
+
+
+ ); +} diff --git a/src/components/editor/type.ts b/src/components/editor/type.ts index db5cd11..6573121 100644 --- a/src/components/editor/type.ts +++ b/src/components/editor/type.ts @@ -52,6 +52,9 @@ export type EditorState = { export type EditorData = { elements: EditorElement[]; + fab: { + type: "" | "invitation_response"; + }; }; export type EditorHistory = { diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..efdb086 --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "~/lib/utils"; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = "DrawerContent"; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = "DrawerHeader"; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = "DrawerFooter"; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerPortal, + DrawerTitle, + DrawerTrigger, +};