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 (
+
+ );
+}
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 (
+
+ {word}
+
+ );
+ }
+
+ return (
+
+ {word.split("").map((char, charIndex) => (
+
+ {char}
+
+ ))}
+
+ );
+});
+
+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",
},
},
},