- {isPending &&
}
{isCoachMarkOpen &&
}
diff --git a/apps/client/src/pages/index.ts b/apps/client/src/pages/index.ts
index 90faf1f..a4d2149 100644
--- a/apps/client/src/pages/index.ts
+++ b/apps/client/src/pages/index.ts
@@ -1,3 +1,5 @@
export { HomePage } from './HomePage/HomePage';
export { NotFound } from './NotFound/NotFound';
export { WorkspacePage } from './Workspacepage/WorkspacePage';
+export { ErrorPage } from './ErrorPage/ErrorPage';
+export { WorkspaceErrorPage } from './WorkspaceErrorPage/WorkspaceErrorPage';
diff --git a/apps/client/src/shared/hooks/index.ts b/apps/client/src/shared/hooks/index.ts
index 94bde05..adaa1f7 100644
--- a/apps/client/src/shared/hooks/index.ts
+++ b/apps/client/src/shared/hooks/index.ts
@@ -7,7 +7,6 @@ export { useSaveWorkspace } from './queries/useSaveWorkspace';
export { usePostImage } from './queries/usePostImage';
export { useDeleteImage } from './queries/useDeleteImage';
-export { useWindowSize } from './css/useWindowSize';
export { useCssTooltip } from './css/useCssTooltip';
export { useCssOptions } from './css/useCssOptions';
export { useCssOptionItem } from './css/useCssOptionItem';
@@ -16,3 +15,6 @@ export { workspaceKeys } from './query-key/workspaceKeys';
export { usePreventLeaveWorkspacePage } from './usePreventLeaveWorkspacePage';
export { useInfiniteScroll } from './useInfiniteScroll';
+export { useScrollPosition } from './useScrollPosition';
+export { useWindowSize } from './useWindowSize';
+export { useVirtualScroll } from './useVirtualScroll';
diff --git a/apps/client/src/shared/hooks/queries/useGetWorkspace.ts b/apps/client/src/shared/hooks/queries/useGetWorkspace.ts
index 23cb790..f238d33 100644
--- a/apps/client/src/shared/hooks/queries/useGetWorkspace.ts
+++ b/apps/client/src/shared/hooks/queries/useGetWorkspace.ts
@@ -3,16 +3,15 @@ import { createUserId, getUserId, removeCssClassNamePrefix } from '@/shared/util
import {
useClassBlockStore,
useCssPropsStore,
+ useImageModalStore,
useResetCssStore,
useWorkspaceChangeStatusStore,
useWorkspaceStore,
- useImageModalStore,
} from '@/shared/store';
import { WorkspaceApi } from '@/shared/api';
-import toast from 'react-hot-toast';
import { useEffect } from 'react';
-import { useQuery } from '@tanstack/react-query';
+import { useSuspenseQuery } from '@tanstack/react-query';
import { workspaceKeys } from '@/shared/hooks';
export const useGetWorkspace = (workspaceId: string) => {
@@ -24,29 +23,19 @@ export const useGetWorkspace = (workspaceId: string) => {
const { resetChangedStatusState } = useWorkspaceChangeStatusStore();
const { setIsResetCssChecked } = useResetCssStore();
const { setInitialImageMap, setInitialImageList } = useImageModalStore();
- const { data, isPending, isError } = useQuery({
+ const { data, isPending, isError } = useSuspenseQuery({
queryKey: workspaceKeys.detail(workspaceId),
queryFn: () => {
+ resetChangedStatusState();
return workspaceApi.getWorkspace(userId, workspaceId);
},
});
useEffect(() => {
- resetChangedStatusState();
- }, []);
-
- useEffect(() => {
- if (isError) {
- toast.error('워크스페이스 정보 불러오기 실패');
- return;
- }
- if (!data) {
+ if (isError || !data || !data.workspaceDto) {
return;
}
- if (!data.workspaceDto) {
- return;
- }
setName(data.workspaceDto.name);
Object.keys(data.workspaceDto.totalCssPropertyObj).forEach((className) => {
createCssClassBlock(className);
diff --git a/apps/client/src/shared/hooks/queries/useGetWorkspaceList.ts b/apps/client/src/shared/hooks/queries/useGetWorkspaceList.ts
index b8f5539..891de16 100644
--- a/apps/client/src/shared/hooks/queries/useGetWorkspaceList.ts
+++ b/apps/client/src/shared/hooks/queries/useGetWorkspaceList.ts
@@ -1,6 +1,7 @@
-import { WorkspaceApi } from '@/shared/api';
import { createUserId, getUserId } from '@/shared/utils';
-import { useInfiniteQuery } from '@tanstack/react-query';
+
+import { WorkspaceApi } from '@/shared/api';
+import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { workspaceKeys } from '@/shared/hooks';
export const useGetWorkspaceList = () => {
const workspaceApi = WorkspaceApi();
@@ -12,7 +13,7 @@ export const useGetWorkspaceList = () => {
isFetchingNextPage,
isError,
data: workspaceList,
- } = useInfiniteQuery({
+ } = useSuspenseInfiniteQuery({
queryKey: workspaceKeys.list(),
queryFn: async ({ pageParam }) => {
const isNewUser = !getUserId();
diff --git a/apps/client/src/shared/hooks/useScrollPosition.ts b/apps/client/src/shared/hooks/useScrollPosition.ts
new file mode 100644
index 0000000..c0a40bc
--- /dev/null
+++ b/apps/client/src/shared/hooks/useScrollPosition.ts
@@ -0,0 +1,25 @@
+import { useEffect, useRef, useState } from 'react';
+
+export const useScrollPosition = () => {
+ const [scrollPosition, setScrollPosition] = useState
(0);
+ const ticking = useRef(false);
+
+ useEffect(() => {
+ const onScroll = () => {
+ if (!ticking.current) {
+ requestAnimationFrame(() => {
+ setScrollPosition(window.scrollY);
+ ticking.current = false;
+ });
+ ticking.current = true;
+ }
+ };
+ window.addEventListener('scroll', onScroll);
+
+ return () => {
+ window.removeEventListener('scroll', onScroll);
+ };
+ }, []);
+
+ return { scrollPosition };
+};
diff --git a/apps/client/src/shared/hooks/useVirtualScroll.ts b/apps/client/src/shared/hooks/useVirtualScroll.ts
new file mode 100644
index 0000000..c412b6e
--- /dev/null
+++ b/apps/client/src/shared/hooks/useVirtualScroll.ts
@@ -0,0 +1,56 @@
+import { useEffect, useState } from 'react';
+import { useScrollPosition, useWindowSize } from '@/shared/hooks';
+
+const calculateNumOfNodePadding = (windowWidth: number): number => {
+ if (windowWidth < 640) {
+ return 1;
+ } else if (windowWidth < 768) {
+ return 2;
+ } else if (windowWidth < 1024) {
+ return 3;
+ }
+ return 4;
+};
+
+export const useVirtualScroll = ({
+ data,
+ topSectionHeight = 0,
+ renderedItemHeight,
+ gapY = 0,
+}: {
+ data: T[] | undefined;
+ topSectionHeight?: number;
+ renderedItemHeight: number;
+ gapY: number;
+}) => {
+ const { scrollPosition } = useScrollPosition();
+ const { screenHeight, screenWidth } = useWindowSize();
+
+ const [renderedData, setRenderedData] = useState([]);
+
+ const nodePadding = calculateNumOfNodePadding(screenWidth);
+ const start = Math.max(
+ 0,
+ Math.floor((scrollPosition - topSectionHeight) / (renderedItemHeight + gapY)) * nodePadding
+ );
+ const offsetY = Math.floor(start / nodePadding) * (renderedItemHeight + gapY);
+ const [totalHeight, setTotalHeight] = useState(0);
+
+ useEffect(() => {
+ if (!data) {
+ return;
+ }
+
+ setRenderedData(
+ data.slice(
+ start,
+ start +
+ Math.floor(screenHeight / (renderedItemHeight + gapY)) * nodePadding +
+ 2 * nodePadding
+ )
+ );
+ setTotalHeight(Math.ceil(data!.length / nodePadding) * (renderedItemHeight + gapY));
+ }, [start, screenHeight, data]);
+
+ return { renderedData, offsetY, totalHeight };
+};
diff --git a/apps/client/src/shared/hooks/css/useWindowSize.ts b/apps/client/src/shared/hooks/useWindowSize.ts
similarity index 100%
rename from apps/client/src/shared/hooks/css/useWindowSize.ts
rename to apps/client/src/shared/hooks/useWindowSize.ts
diff --git a/apps/client/src/shared/store/useCssPropsStore.ts b/apps/client/src/shared/store/useCssPropsStore.ts
index 863d85f..4c1b667 100644
--- a/apps/client/src/shared/store/useCssPropsStore.ts
+++ b/apps/client/src/shared/store/useCssPropsStore.ts
@@ -1,5 +1,6 @@
import { TTotalCssPropertyObj } from '@/shared/types';
import { create } from 'zustand';
+import { removeCssClassNamePrefix, trackEvent } from '../utils';
type TcssProps = {
currentCssClassName: string;
@@ -20,7 +21,7 @@ export const useCssPropsStore = create((set) => {
currentCssClassName: '',
selectedCssCategory: '레이아웃',
totalCssPropertyObj: {},
- addNewCssClass: (newCssClass) =>
+ addNewCssClass: (newCssClass) => {
set((state) => {
if (!state.totalCssPropertyObj[newCssClass]) {
return {
@@ -34,14 +35,28 @@ export const useCssPropsStore = create((set) => {
};
}
return {};
- }),
- setCurrentCssClassName: (currentCssClassName) => set({ currentCssClassName }),
- setSelectedCssCategory: (selectedCssCategory) => set({ selectedCssCategory }),
- setCheckedCssPropertyObj: (className, label, value) =>
+ });
+ trackEvent(`css_class_created`, {
+ item: newCssClass,
+ });
+ },
+ setCurrentCssClassName: (currentCssClassName) => {
+ set({ currentCssClassName });
+ trackEvent(`css_class_selected`, {
+ item: removeCssClassNamePrefix(currentCssClassName),
+ });
+ },
+ setSelectedCssCategory: (selectedCssCategory) => {
+ set({ selectedCssCategory });
+ trackEvent(`css_category_selected`, {
+ item: selectedCssCategory,
+ });
+ },
+ setCheckedCssPropertyObj: (className, label, value) => {
set((state) => {
const updatedObj = state.totalCssPropertyObj[className] || {
checkedCssPropertyObj: {},
- cssOptionObh: {},
+ cssOptionObj: {},
};
updatedObj.checkedCssPropertyObj[label] = value;
return {
@@ -50,8 +65,12 @@ export const useCssPropsStore = create((set) => {
[className]: updatedObj,
},
};
- }),
- setCssOptionObj: (className, label, value) =>
+ });
+ trackEvent(`css_category_item_checked`, {
+ item: className,
+ });
+ },
+ setCssOptionObj: (className, label, value) => {
set((state) => {
const updatedObj = state.totalCssPropertyObj[className] || {
checkedCssPropertyObj: {},
@@ -64,7 +83,12 @@ export const useCssPropsStore = create((set) => {
[className]: updatedObj,
},
};
- }),
+ });
+ trackEvent(`css_category_item_inputted`, {
+ item: label,
+ value: value,
+ });
+ },
initCssPropertyObj: (totalCssPropertyObj) =>
set({ totalCssPropertyObj, currentCssClassName: '', selectedCssCategory: '레이아웃' }),
diff --git a/apps/client/src/shared/ui/index.ts b/apps/client/src/shared/ui/index.ts
index d02e17c..92d3cc6 100644
--- a/apps/client/src/shared/ui/index.ts
+++ b/apps/client/src/shared/ui/index.ts
@@ -9,6 +9,7 @@ export { ToasterWithMax } from './toast/ToasterWithMax';
export { Loading } from './loading/Loading';
export { Spinner } from './loading/Spinner';
+export { DelayedFallback } from './loading/DelayedFallback';
export { SkeletonWorkspace } from './skeleton/SkeletonWorkspace';
export { SkeletonWorkspaceList } from './skeleton/SkeletonWorkspaceList';
diff --git a/apps/client/src/shared/ui/loading/DelayedFallback.tsx b/apps/client/src/shared/ui/loading/DelayedFallback.tsx
new file mode 100644
index 0000000..45f088c
--- /dev/null
+++ b/apps/client/src/shared/ui/loading/DelayedFallback.tsx
@@ -0,0 +1,19 @@
+import { ReactNode, useEffect, useState } from 'react';
+
+type DelayedFallbackProps = {
+ children: ReactNode;
+ delay?: number;
+};
+
+export const DelayedFallback = ({ children, delay = 50 }: DelayedFallbackProps) => {
+ const [showFallback, setShowFallback] = useState(false);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setShowFallback(true);
+ }, delay);
+ return () => clearTimeout(timer);
+ }, [delay]);
+
+ return <>{showFallback ? children : null} >;
+};
diff --git a/apps/client/src/shared/utils/index.ts b/apps/client/src/shared/utils/index.ts
index 04e16c7..e31b41b 100644
--- a/apps/client/src/shared/utils/index.ts
+++ b/apps/client/src/shared/utils/index.ts
@@ -17,3 +17,4 @@ export { capturePreview } from './capturePreview';
export { coachMarkContent } from './coachMarkContent';
export { IFRAME_ERROR_MESSAGE } from './iframeErrorMessage';
export { exportPreviewHtml } from './exportPreviewHtml';
+export { initMixpanel, trackEvent } from './mixpanel';
diff --git a/apps/client/src/shared/utils/mixpanel.ts b/apps/client/src/shared/utils/mixpanel.ts
new file mode 100644
index 0000000..6b080dc
--- /dev/null
+++ b/apps/client/src/shared/utils/mixpanel.ts
@@ -0,0 +1,28 @@
+import mixpanel from 'mixpanel-browser';
+
+const isProduction = import.meta.env.MODE === 'production';
+
+export const initMixpanel = () => {
+ const MIXPANEL_TOKEN = import.meta.env.VITE_MIXPANEL_TOKEN;
+ mixpanel.init(MIXPANEL_TOKEN, { debug: true });
+ _connectUser();
+};
+
+const _connectUser = () => {
+ const userId = localStorage.getItem('userId');
+ if (userId) {
+ mixpanel.identify(userId);
+ }
+};
+
+export const trackEvent = (eventName: string, properties?: Record) => {
+ const baseProperties = {
+ debug: !isProduction,
+ userId: localStorage.getItem('userId') || 'anonymous',
+ timeStamp: new Date().toISOString(),
+ };
+ mixpanel.track(eventName, {
+ ...baseProperties,
+ ...properties,
+ });
+};
diff --git a/apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.stories.tsx b/apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.stories.tsx
index 7b22b86..f2b36a3 100644
--- a/apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.stories.tsx
+++ b/apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.stories.tsx
@@ -6,7 +6,7 @@ const meta: Meta = {
title: 'widgets/home/WorkspaceContainer',
component: WorkspaceContainer,
parameters: {
- layout: 'fullscreen',
+ layout: 'centered',
},
tags: ['autodocs'],
};
diff --git a/apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.tsx b/apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.tsx
index 491b760..8da8a85 100644
--- a/apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.tsx
+++ b/apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.tsx
@@ -1,18 +1,25 @@
-import { EmptyWorkspace, WorkspaceGrid, WorkspaceHeader, WorkspaceList } from '@/widgets';
-import { useGetWorkspaceList, useInfiniteScroll } from '@/shared/hooks';
+import { EmptyWorkspace, WorkspaceGrid, WorkspaceList } from '@/widgets';
+import { useGetWorkspaceList, useInfiniteScroll, useVirtualScroll } from '@/shared/hooks';
import { SkeletonWorkspaceList } from '@/shared/ui';
-import { WorkspaceLoadError } from '@/entities';
+import { TWorkspace } from '@/shared/types';
/**
*
* @description
- * 워크스페이스 헤더와 그리드를 감싸는 컨테이너 컴포넌트
+ * 워크스페이스 리스트를 렌더링하는 컨테이너 컴포넌트
*/
export const WorkspaceContainer = () => {
- const { hasNextPage, fetchNextPage, isPending, isFetchingNextPage, isError, workspaceList } =
+ const { hasNextPage, fetchNextPage, isPending, isFetchingNextPage, workspaceList } =
useGetWorkspaceList();
+ const { renderedData, offsetY, totalHeight } = useVirtualScroll({
+ data: workspaceList,
+ topSectionHeight: 594,
+ renderedItemHeight: 262,
+ gapY: 32,
+ });
+
const fetchCallback: IntersectionObserverCallback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting && hasNextPage) {
@@ -25,29 +32,25 @@ export const WorkspaceContainer = () => {
const nextFetchTargetRef = useInfiniteScroll({ intersectionCallback: fetchCallback });
return (
-
-
- {isPending && (
-
-
-
- )}
- {isError ? (
-
- ) : (
- workspaceList &&
+ <>
+ {workspaceList &&
(workspaceList.length === 0 ? (
) : (
-
-
- {isFetchingNextPage && }
-
- ))
- )}
+
+
+
+ {isFetchingNextPage && }
+
+
+ ))}
{!isPending && !isFetchingNextPage && hasNextPage && (
)}
-
+ >
);
};
diff --git a/apps/client/src/widgets/home/WorkspaceGrid/WorkspaceGrid.tsx b/apps/client/src/widgets/home/WorkspaceGrid/WorkspaceGrid.tsx
index 63e16f1..130444d 100644
--- a/apps/client/src/widgets/home/WorkspaceGrid/WorkspaceGrid.tsx
+++ b/apps/client/src/widgets/home/WorkspaceGrid/WorkspaceGrid.tsx
@@ -1,13 +1,22 @@
-import { PropsWithChildren } from 'react';
+import { ReactNode } from 'react';
/**
*
* @description
* 워크스페이스 그리드 컴포넌트
*/
-export const WorkspaceGrid = ({ children }: PropsWithChildren) => {
+export const WorkspaceGrid = ({
+ offsetY = 0,
+ children,
+}: {
+ offsetY?: number;
+ children: ReactNode;
+}) => {
return (
-
+
0 ? `translateY(${offsetY + 32}px)` : 'none' }}
+ will-change="transform"
+ >
{children}
diff --git a/apps/client/src/widgets/home/WorkspaceSection/WorkspaceSection.stories.tsx b/apps/client/src/widgets/home/WorkspaceSection/WorkspaceSection.stories.tsx
new file mode 100644
index 0000000..0ab8538
--- /dev/null
+++ b/apps/client/src/widgets/home/WorkspaceSection/WorkspaceSection.stories.tsx
@@ -0,0 +1,22 @@
+import { Meta, StoryObj } from '@storybook/react';
+
+import { WorkspaceSection } from './WorkspaceSection';
+
+const meta: Meta
= {
+ title: 'widgets/home/WorkspaceSection',
+ component: WorkspaceSection,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ // propsname: value,
+ },
+};
diff --git a/apps/client/src/widgets/home/WorkspaceSection/WorkspaceSection.tsx b/apps/client/src/widgets/home/WorkspaceSection/WorkspaceSection.tsx
new file mode 100644
index 0000000..a62b2ca
--- /dev/null
+++ b/apps/client/src/widgets/home/WorkspaceSection/WorkspaceSection.tsx
@@ -0,0 +1,26 @@
+import { WorkspaceContainer, WorkspaceGrid, WorkspaceHeader } from '@/widgets';
+
+import { SkeletonWorkspaceList } from '@/shared/ui';
+import { Suspense } from 'react';
+
+/**
+ *
+ * @description
+ * 워크스페이스 헤더와 컨테이너를 합친 섹션 컴포넌트
+ */
+export const WorkspaceSection = () => {
+ return (
+
+ );
+};
diff --git a/apps/client/src/widgets/index.ts b/apps/client/src/widgets/index.ts
index 68e0015..b4ddcd0 100644
--- a/apps/client/src/widgets/index.ts
+++ b/apps/client/src/widgets/index.ts
@@ -4,8 +4,9 @@ export { WorkspaceList } from './home/WorkspaceList/WorkspaceList';
export { WorkspaceHeader } from './home/WorkspaceHeader/WorkspaceHeader';
export { EmptyWorkspace } from './home/EmptyWorkspace/EmptyWorkspace';
export { WorkspaceGrid } from './home/WorkspaceGrid/WorkspaceGrid';
-export { WorkspaceContainer } from './home/WorkspaceContainer/WorkspaceContainer';
+export { WorkspaceSection } from './home/WorkspaceSection/WorkspaceSection';
export { WorkspaceModal } from './home/WorkspaceModal/WorkspaceModal';
+export { WorkspaceContainer } from './home/WorkspaceContainer/WorkspaceContainer';
export { PreviewBox } from './workspace/PreviewBox/PreviewBox';
export { CoachMark } from './workspace/CoachMark/CoachMark';
diff --git a/apps/client/src/widgets/workspace/PreviewBox/PreviewBox.tsx b/apps/client/src/widgets/workspace/PreviewBox/PreviewBox.tsx
index 222185a..308a9eb 100644
--- a/apps/client/src/widgets/workspace/PreviewBox/PreviewBox.tsx
+++ b/apps/client/src/widgets/workspace/PreviewBox/PreviewBox.tsx
@@ -6,6 +6,7 @@ import CopyIcon from '@/shared/assets/code_copy.svg?react';
import { resetCss } from '@/shared/utils/resetCss';
import toast from 'react-hot-toast';
import { useCoachMarkStore } from '@/shared/store/useCoachMarkStore';
+import { trackEvent } from '@/shared/utils';
type PreviewBoxProps = {
htmlCode: string;
@@ -27,7 +28,8 @@ export const PreviewBox = ({
selectedBlockLength,
selectedBlockType,
}: PreviewBoxProps) => {
- const [activeTab, setActiveTab] = useState<'preview' | 'html' | 'css'>('preview');
+ type TTab = 'preview' | 'html' | 'css';
+ const [activeTab, setActiveTab] = useState('preview');
const { isResetCssChecked } = useResetCssStore();
const { currentStep } = useCoachMarkStore();
const { setIframeRef } = useIframeStore();
@@ -67,6 +69,13 @@ export const PreviewBox = ({
copyToClipboard(codeToCopy, label);
};
+ const handleActiveTab = (type: TTab) => {
+ setActiveTab(type);
+ trackEvent(`preview_tab_clicked`, {
+ item: type,
+ });
+ };
+
return (
// TODO: 사용자가이드 - 겹치는 현상