Skip to content

Commit c05e6ff

Browse files
authored
[IMPROVE] 토픽 페이지 초기 로딩 속도 개선 (#56)
* feat: 공통으로 사용할 기본 스피너 컴포넌트 구현 * refactor: suspense query + dynamic 조합으로 리팩토링 * refactor: spinner 컴포넌트 packages로 분리 * feat: ReactFlow 컴포넌트 가져올 때 dynamic 적용 * feat: 나의 토픽 컴포넌트에 dynamic 적용 * refactor: 의존성 배열 누락된 변수들 추가 * feat: 토픽 상세 layout에서 modal 컴포넌트 가져올 때 dynamic 적용
1 parent c63003d commit c05e6ff

13 files changed

Lines changed: 241 additions & 198 deletions

File tree

apps/web/src/app/(with-side-bar)/topic/[id]/layout.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import TopicStickerDetailModal from "@/components/(with-side-bar)/topic/sticker/topic-sticker-detail-modal";
1+
"use client";
2+
3+
import dynamic from "next/dynamic";
4+
5+
const TopicStickerDetailModal = dynamic(
6+
() => import("@/components/(with-side-bar)/topic/sticker/topic-sticker-detail-modal"),
7+
{
8+
ssr: false,
9+
}
10+
);
211

312
interface TopicBoardLayoutProps {
413
children: React.ReactNode;

apps/web/src/app/globals.css

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,6 @@ div.customHandle {
7676
rgba(225, 217, 253, 0.8) 46.63%,
7777
rgba(225, 216, 255, 0) 100%
7878
);
79-
/* background: radial-gradient(
80-
50% 50% at 50% 50%,
81-
#ccbeff 0%,
82-
rgba(204, 190, 255, 0.8) 46.63%,
83-
rgba(225, 216, 255, 0) 100%
84-
); */
8579
}
8680

8781
@utility bg-radial-gradient-duo {

apps/web/src/components/(with-side-bar)/layout/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use client";
22

3+
import { Suspense } from "react";
4+
import dynamic from "next/dynamic";
35
import Link from "next/link";
46
import { usePathname } from "next/navigation";
57

@@ -9,12 +11,16 @@ import { MINUTE } from "@/constants/time";
911
import { queryClient } from "@/lib/tanstack";
1012
import { useMobileMenuStore } from "@/lib/zustand/mobile-menu";
1113
import { getCategories } from "@/services/category";
14+
import { Spinner } from "@linkyboard/components";
1215
import { cn } from "@linkyboard/utils";
1316

1417
import type { LucideIcon } from "lucide-react";
1518
import { Book, Grid3X3, Home } from "lucide-react";
1619

17-
import RecentTopicList from "./recent-topic-list";
20+
const RecentTopicList = dynamic(() => import("./recent-topic-list"), {
21+
ssr: false,
22+
loading: () => <Spinner className="mx-auto" />,
23+
});
1824

1925
interface NavItem {
2026
icon: LucideIcon;
@@ -89,7 +95,9 @@ export default function Sidebar() {
8995
<div className="text-muted-foreground mb-4 text-sm font-semibold uppercase tracking-wider">
9096
나의 토픽
9197
</div>
92-
<RecentTopicList />
98+
<Suspense fallback={<Spinner className="mx-auto" />}>
99+
<RecentTopicList />
100+
</Suspense>
93101
</div>
94102
</aside>
95103

apps/web/src/components/(with-side-bar)/layout/recent-topic-list.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { useGetAllTopics } from "@/lib/tanstack/query/topic";
77
import { useDashboardStore } from "@/lib/zustand/dashboard";
88
import type { TopicDTO } from "@/models/topic";
99

10-
import { Loader2 } from "lucide-react";
11-
1210
import RecentTopicItem from "./recent-topic-item";
1311

1412
export default function RecentTopicList() {
@@ -39,20 +37,16 @@ export default function RecentTopicList() {
3937

4038
useEffect(() => {
4139
if (!isPending) {
42-
setTotalTopics(recentTopics?.data?.length || 0);
40+
setTotalTopics(recentTopics.data.length);
4341
}
44-
}, [isPending]);
42+
}, [isPending, recentTopics, setTotalTopics]);
4543

4644
return (
4745
<>
48-
{isLoading ? (
49-
<div className="flex items-center justify-center">
50-
<Loader2 className="animate-spin" />
51-
</div>
52-
) : !recentTopics || recentTopics?.data?.length === 0 ? (
46+
{recentTopics.data.length === 0 ? (
5347
<p className="text-muted-foreground text-center text-sm">토픽이 없어요.</p>
5448
) : (
55-
recentTopics?.data?.map((topic) => (
49+
recentTopics.data.map((topic) => (
5650
<RecentTopicItem
5751
key={topic.id}
5852
isSelected={isSelected(topic.id)}
Lines changed: 27 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,32 @@
1-
"use client";
1+
import { useMemo } from "react";
22

3-
import { useMemo, useRef, useState } from "react";
4-
import Link from "next/link";
5-
6-
import ResizeBar from "@/components/common/resize-bar";
7-
import { CONTENT_TYPE_OPTIONS, type ContentTypeOptions } from "@/constants/content";
8-
import { useDebounce } from "@/hooks/use-debounce";
3+
import type { ContentTypeOptions } from "@/constants/content";
94
import { useCreateContent } from "@/lib/tanstack/mutation/topic-content";
105
import { useGetAllContents } from "@/lib/tanstack/query/topic";
11-
import type { TopicNodeProps } from "@/types/topic";
12-
import { Button, Input } from "@linkyboard/components";
136
import type { CategoryContentDTO } from "@linkyboard/types";
147

15-
import { Loader2, Search } from "lucide-react";
16-
178
import SentinelSpinner from "../../common/sentinel-spinner";
189
import ContentItem from "../library/content-item";
1910

20-
interface AddContentListProps {
11+
interface ContentListProps {
12+
query: string;
2113
type: ContentTypeOptions;
2214
id: string;
23-
isTopicLoading: boolean;
24-
nodes: TopicNodeProps[];
2515
}
2616

27-
const MIN_WIDTH = 300;
28-
const MAX_WIDTH = 600;
29-
30-
export default function AddContentList({ type, id, isTopicLoading, nodes }: AddContentListProps) {
31-
const [contentPanelWidth, setContentPanelWidth] = useState(300); // Content Panel 기본 너비
32-
const [searchQuery, setSearchQuery] = useState("");
33-
34-
const contentPanelRef = useRef<HTMLDivElement | null>(null);
35-
36-
const debouncedSearchQuery = useDebounce(searchQuery, 300);
37-
17+
export default function AddContentList({ query, id, type }: ContentListProps) {
3818
const {
3919
data: contents,
4020
isLoading,
4121
fetchNextPage,
4222
hasNextPage,
4323
isFetchingNextPage,
4424
} = useGetAllContents(type);
45-
const { mutateAsync: createContent, isPending } = useCreateContent(id);
25+
const { mutateAsync: createContent } = useCreateContent(id);
4626

4727
const filteredContents = useMemo(() => {
48-
const filteredBySearch = contents?.filter((content) =>
49-
content.title.toLowerCase().includes(debouncedSearchQuery.toLowerCase())
50-
);
51-
return filteredBySearch?.filter(
52-
(content) => !nodes.some((node) => node.data.item.id === content.id)
53-
);
54-
}, [contents, nodes, debouncedSearchQuery]);
55-
56-
const onSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
57-
setSearchQuery(event.target.value);
58-
};
59-
60-
const onMouseMove = (e: MouseEvent) => {
61-
if (!contentPanelRef.current) return;
62-
63-
const containerRect = contentPanelRef.current.getBoundingClientRect();
64-
const newWidth = e.clientX - containerRect.left;
65-
66-
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
67-
setContentPanelWidth(newWidth);
68-
}
69-
};
28+
return contents.filter((content) => content.title.toLowerCase().includes(query.toLowerCase()));
29+
}, [contents, query]);
7030

7131
const onAddContent = async (content: CategoryContentDTO) => {
7232
await createContent({
@@ -78,75 +38,25 @@ export default function AddContentList({ type, id, isTopicLoading, nodes }: AddC
7838
};
7939

8040
return (
81-
<>
82-
<div
83-
ref={contentPanelRef}
84-
className="bg-card border-border overflow-hidden rounded-l-lg border border-r-0"
85-
style={{ width: `${contentPanelWidth}px`, minWidth: "300px" }}
86-
>
87-
<div className="relative p-4">
88-
<Search
89-
className="text-muted-foreground absolute left-8 top-1/2 -translate-y-1/2 transform"
90-
size={16}
91-
/>
92-
<Input
93-
type="text"
94-
placeholder="콘텐츠를 검색하세요"
95-
className="pl-10"
96-
value={searchQuery}
97-
onChange={onSearchChange}
98-
/>
99-
</div>
100-
<div className="flex gap-2 px-4 pb-4 shadow">
101-
<Button
102-
variant={type === CONTENT_TYPE_OPTIONS.ALL ? "default" : "outline"}
103-
size="sm"
104-
asChild
105-
>
106-
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.ALL}`}>모두</Link>
107-
</Button>
108-
<Button
109-
variant={type === CONTENT_TYPE_OPTIONS.WEB ? "default" : "outline"}
110-
size="sm"
111-
asChild
112-
>
113-
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.WEB}`}></Link>
114-
</Button>
115-
<Button
116-
variant={type === CONTENT_TYPE_OPTIONS.YOUTUBE ? "default" : "outline"}
117-
size="sm"
118-
asChild
119-
>
120-
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.YOUTUBE}`}>유튜브</Link>
121-
</Button>
122-
</div>
123-
<div className="flex h-[calc(100%-120px)] flex-col gap-3 overflow-y-auto p-4">
124-
{isLoading || isTopicLoading ? (
125-
<div className="flex justify-center">
126-
<Loader2 className="text-muted-foreground animate-spin" />
127-
</div>
128-
) : filteredContents?.length === 0 ? (
129-
<p className="text-muted-foreground text-center">저장된 콘텐츠가 없어요</p>
130-
) : (
131-
filteredContents?.map((item) => (
132-
<ContentItem
133-
draggable
134-
key={`${item.id}-content-item`}
135-
item={item}
136-
onClick={() => onAddContent(item)}
137-
disabled={isPending}
138-
/>
139-
))
140-
)}
141-
<SentinelSpinner
142-
fetchNextPage={fetchNextPage}
143-
hasNextPage={hasNextPage}
144-
isLoading={isLoading}
145-
isFetchingNextPage={isFetchingNextPage}
41+
<div className="flex h-[calc(100%-120px)] flex-col gap-3 overflow-y-auto p-4">
42+
{filteredContents.length === 0 ? (
43+
<p className="text-muted-foreground text-center">저장된 콘텐츠가 없어요</p>
44+
) : (
45+
filteredContents.map((item) => (
46+
<ContentItem
47+
draggable
48+
key={`${item.id}-content-item`}
49+
item={item}
50+
onClick={() => onAddContent(item)}
14651
/>
147-
</div>
148-
</div>
149-
<ResizeBar onMouseMove={onMouseMove} />
150-
</>
52+
))
53+
)}
54+
<SentinelSpinner
55+
fetchNextPage={fetchNextPage}
56+
hasNextPage={hasNextPage}
57+
isLoading={isLoading}
58+
isFetchingNextPage={isFetchingNextPage}
59+
/>
60+
</div>
15161
);
15262
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"use client";
2+
3+
import { Suspense, useRef, useState } from "react";
4+
import dynamic from "next/dynamic";
5+
import Link from "next/link";
6+
7+
import ResizeBar from "@/components/common/resize-bar";
8+
import { CONTENT_TYPE_OPTIONS, type ContentTypeOptions } from "@/constants/content";
9+
import { useDebounce } from "@/hooks/use-debounce";
10+
import { Button, Input, Spinner } from "@linkyboard/components";
11+
12+
import { Search } from "lucide-react";
13+
14+
const AddContentList = dynamic(() => import("./add-content-list"), {
15+
ssr: false,
16+
loading: () => (
17+
<div className="flex justify-center py-2">
18+
<Spinner />
19+
</div>
20+
),
21+
});
22+
23+
interface AddContentListProps {
24+
type: ContentTypeOptions;
25+
id: string;
26+
}
27+
28+
const MIN_WIDTH = 300;
29+
const MAX_WIDTH = 600;
30+
31+
export default function AddContent({ type, id }: AddContentListProps) {
32+
const [contentPanelWidth, setContentPanelWidth] = useState(300); // Content Panel 기본 너비
33+
const [searchQuery, setSearchQuery] = useState("");
34+
35+
const contentPanelRef = useRef<HTMLDivElement | null>(null);
36+
37+
const debouncedSearchQuery = useDebounce(searchQuery, 300);
38+
39+
const onMouseMove = (e: MouseEvent) => {
40+
if (!contentPanelRef.current) return;
41+
42+
const containerRect = contentPanelRef.current.getBoundingClientRect();
43+
const newWidth = e.clientX - containerRect.left;
44+
45+
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
46+
setContentPanelWidth(newWidth);
47+
}
48+
};
49+
50+
return (
51+
<>
52+
<div
53+
ref={contentPanelRef}
54+
className="bg-card border-border overflow-hidden rounded-l-lg border border-r-0"
55+
style={{ width: `${contentPanelWidth}px`, minWidth: "300px" }}
56+
>
57+
<div className="relative p-4">
58+
<Search
59+
className="text-muted-foreground absolute left-8 top-1/2 -translate-y-1/2 transform"
60+
size={16}
61+
/>
62+
<Input
63+
type="text"
64+
placeholder="콘텐츠를 검색하세요"
65+
className="pl-10"
66+
value={searchQuery}
67+
onChange={(e) => setSearchQuery(e.target.value)}
68+
/>
69+
</div>
70+
<div className="flex gap-2 px-4 pb-4 shadow">
71+
<Button
72+
variant={type === CONTENT_TYPE_OPTIONS.ALL ? "default" : "outline"}
73+
size="sm"
74+
asChild
75+
>
76+
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.ALL}`}>모두</Link>
77+
</Button>
78+
<Button
79+
variant={type === CONTENT_TYPE_OPTIONS.WEB ? "default" : "outline"}
80+
size="sm"
81+
asChild
82+
>
83+
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.WEB}`}></Link>
84+
</Button>
85+
<Button
86+
variant={type === CONTENT_TYPE_OPTIONS.YOUTUBE ? "default" : "outline"}
87+
size="sm"
88+
asChild
89+
>
90+
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.YOUTUBE}`}>유튜브</Link>
91+
</Button>
92+
</div>
93+
<Suspense
94+
fallback={
95+
<div className="flex justify-center py-2">
96+
<Spinner />
97+
</div>
98+
}
99+
>
100+
<AddContentList query={debouncedSearchQuery} id={id} type={type} />
101+
</Suspense>
102+
</div>
103+
<ResizeBar onMouseMove={onMouseMove} />
104+
</>
105+
);
106+
}

0 commit comments

Comments
 (0)