diff --git a/bun.lockb b/bun.lockb index 2815c07..fbb48e0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 6aae44e..f5070a0 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "drizzle-orm": "^0.33.0", "es-hangul": "^1.4.2", "es-toolkit": "^1.13.1", + "framer-motion": "^11.3.28", "ky": "^1.4.0", "lucia": "^3.2.0", "lucide-react": "^0.414.0", diff --git a/src/app/(main)/dashboard/template-item.tsx b/src/app/(main)/dashboard/template-item.tsx index 39b12d0..cfe982c 100644 --- a/src/app/(main)/dashboard/template-item.tsx +++ b/src/app/(main)/dashboard/template-item.tsx @@ -1,31 +1,42 @@ "use client"; import { useMutation } from "@tanstack/react-query"; +import { delay } from "es-toolkit"; import { PlusIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { useLoadingStore } from "~/components/gloabl-loading"; import { createInvitation } from "~/lib/db/schema/invitations.query"; import type { Template } from "~/lib/db/schema/templates"; export default function TemplateItem({ template }: { template: Template }) { const router = useRouter(); + const loadingLayer = useLoadingStore(); const createMutation = useMutation({ mutationFn: async () => { - // TODO: global loading - return await createInvitation({ - title: template.title, - thumbnailUrl: template.thumbnailUrl, - customFields: template.customFields, - }); + loadingLayer.open("초대장을 만들고 있어요..."); + + const [data] = await Promise.all([ + await createInvitation({ + title: template.title, + thumbnailUrl: template.thumbnailUrl, + customFields: template.customFields, + }), + await delay(1000), + ]); + + return data; }, onSuccess: (data) => { toast.success("초대장이 생성되었습니다."); router.push(`/i/${data.eventUrl}/edit`); + loadingLayer.close(); }, onError: (error) => { console.error("Error creating invitation:", error); toast.error("초대장 생성 중 오류가 발생했습니다."); + loadingLayer.close(); }, }); diff --git a/src/app/(main)/loading.tsx b/src/app/(main)/loading.tsx new file mode 100644 index 0000000..921a89a --- /dev/null +++ b/src/app/(main)/loading.tsx @@ -0,0 +1,25 @@ +export default function Loading() { + return ( +
+
+ + Loading... +
+
+ ); +} diff --git a/src/components/core/in-view.tsx b/src/components/core/in-view.tsx new file mode 100644 index 0000000..285ba61 --- /dev/null +++ b/src/components/core/in-view.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { + motion, + type Transition, + useInView, + type UseInViewOptions, + type Variant, +} from "framer-motion"; +import { type ReactNode, useRef } from "react"; + +interface InViewProps { + children: ReactNode; + variants?: { + hidden: Variant; + visible: Variant; + }; + transition?: Transition; + viewOptions?: UseInViewOptions; + className?: string; +} + +const defaultVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, +}; + +export function InView({ + children, + variants = defaultVariants, + transition, + viewOptions, + className, +}: InViewProps) { + const ref = useRef(null); + const isInView = useInView(ref, viewOptions); + + return ( + + {children} + + ); +} diff --git a/src/components/core/text-effect.tsx b/src/components/core/text-effect.tsx new file mode 100644 index 0000000..3ea3e49 --- /dev/null +++ b/src/components/core/text-effect.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { motion, type Variants } from "framer-motion"; +import React from "react"; + +type PresetType = "blur" | "shake" | "scale" | "fade" | "slide"; + +type TextEffectProps = { + children: string; + per?: "word" | "char"; + as?: keyof JSX.IntrinsicElements; + variants?: { + container?: Variants; + item?: Variants; + }; + className?: string; + preset?: PresetType; +}; + +const defaultContainerVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.05, + }, + }, +}; + +const defaultItemVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + }, +}; + +const presetVariants: Record< + PresetType, + { container: Variants; item: Variants } +> = { + blur: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, filter: "blur(12px)" }, + visible: { opacity: 1, filter: "blur(0px)" }, + }, + }, + shake: { + container: defaultContainerVariants, + item: { + hidden: { x: 0 }, + visible: { x: [-5, 5, -5, 5, 0], transition: { duration: 0.5 } }, + }, + }, + scale: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, scale: 0 }, + visible: { opacity: 1, scale: 1 }, + }, + }, + fade: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, + }, + }, + slide: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0 }, + }, + }, +}; + +const AnimationComponent: React.FC<{ + word: string; + variants: Variants; + per: "word" | "char"; +}> = React.memo(({ word, variants, per }) => { + if (per === "word") { + return ( + + ); + } + + return ( + + {word.split("").map((char, charIndex) => ( + + ))} + + ); +}); + +AnimationComponent.displayName = "AnimationComponent"; + +export function TextEffect({ + children, + per = "word", + as = "p", + variants, + className, + preset, +}: TextEffectProps) { + const words = children.split(/(\S+)/); + const MotionTag = motion[as as keyof typeof motion]; + const selectedVariants = preset + ? presetVariants[preset] + : { container: defaultContainerVariants, item: defaultItemVariants }; + const containerVariants = variants?.container || selectedVariants.container; + const itemVariants = variants?.item || selectedVariants.item; + + return ( + + {words.map((word, wordIndex) => ( + + ))} + + ); +} diff --git a/src/components/editor/navigation.tsx b/src/components/editor/navigation.tsx index 625b5e2..241bc26 100644 --- a/src/components/editor/navigation.tsx +++ b/src/components/editor/navigation.tsx @@ -13,7 +13,7 @@ import { Undo2, } from "lucide-react"; import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { useEditor } from "~/components/editor/provider"; import TitleInput from "~/components/editor/title-input"; @@ -38,8 +38,6 @@ export default function EditorNavigation() { const { editor, dispatch } = useEditor(); const router = useRouter(); const { openDialog } = useAlertDialogStore(); - const params = useParams(); - const subDomain = params.subdomain; const handlePreviewClick = () => { dispatch({ type: "TOGGLE_PREVIEW_MODE" }); @@ -54,19 +52,18 @@ export default function EditorNavigation() { }; const handleOnSave = async () => { - try { - await updateInvitation({ + toast.promise( + updateInvitation({ id: editor.config.invitationId, title: editor.config.invitationTitle, customFields: editor.data, - }); - toast.success("저장되었습니다."); - } catch (error) { - console.error(error); - toast.error("일시적인 오류가 발생되었습니다.", { - description: "잠시후 다시 시도해보세요.", - }); - } + }), + { + loading: "저장중...", + success: "저장되었습니다.", + error: "일시적인 오류가 발생되었습니다.", + }, + ); }; const handleOnDelete = () => { @@ -78,6 +75,7 @@ export default function EditorNavigation() { onConfirm: async () => { await deleteInvitation(editor.config.invitationId); router.replace("/dashboard"); + toast.success("삭제되었습니다."); }, }); }; diff --git a/src/components/editor/sidebar/sidebar-settings-tab.tsx b/src/components/editor/sidebar/sidebar-settings-tab.tsx index f64fcba..123fa26 100644 --- a/src/components/editor/sidebar/sidebar-settings-tab.tsx +++ b/src/components/editor/sidebar/sidebar-settings-tab.tsx @@ -118,10 +118,7 @@ function CustomDomainSection() { type="submit" size="sm" className="ml-2 h-6" - disabled={ - updateSubdomainMutation.isPending || - field.state.value === editor.config.invitationSubdomain - } + disabled={updateSubdomainMutation.isPending} > 저장 diff --git a/src/components/gloabl-loading.tsx b/src/components/gloabl-loading.tsx new file mode 100644 index 0000000..ea6a25d --- /dev/null +++ b/src/components/gloabl-loading.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { create } from "zustand"; +import { TextEffect } from "~/components/core/text-effect"; + +type LoadingStore = { + text?: string; + isOpen: boolean; + open: (text?: string) => void; + close: () => void; +}; + +export const useLoadingStore = create((set) => ({ + isOpen: false, + open: (text) => set({ isOpen: true, text }), + close: () => set({ isOpen: false }), +})); + +export default function GlobalLoading() { + const { text, isOpen } = useLoadingStore(); + + return ( + + {isOpen && ( + + 로딩중... + {text && ( + {text} + )} + + )} + + ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 3db6be6..92479bd 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import type { ReactNode } from "react"; +import GlobalLoading from "~/components/gloabl-loading"; import { GlobalAlert } from "~/components/global-alert"; import { Toaster } from "~/components/ui/sonner"; import { TooltipProvider } from "~/components/ui/tooltip"; @@ -45,6 +46,7 @@ export default function Providers({ children }: { children: ReactNode }) { {children} + ); diff --git a/tailwind.config.ts b/tailwind.config.ts index 08e0a00..1e4c029 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -75,11 +75,35 @@ const config = { transform: "scale(1)", }, }, + "head-shake": { + "0%": { + transform: "translateX(0)", + }, + "6.5%": { + transform: "translateX(-6px) rotateY(-9deg)", + }, + + "18.5%": { + transform: "translateX(5px) rotateY(7deg)", + }, + + "31.5%": { + transform: "translateX(-3px) rotateY(-5deg)", + }, + + "43.5%": { + transform: "translateX(2px) rotateY(3deg)", + }, + "50%": { + transform: "translateX(0)", + }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", "zoom-in": "zoom-in 0.5s ease-out", + "head-shake": "head-shake 2s infinite", }, }, },