Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/web/src/app/(with-side-bar)/topic/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import TopicStickerDetailModal from "@/components/(with-side-bar)/topic/sticker/topic-sticker-detail-modal";
"use client";

import dynamic from "next/dynamic";

const TopicStickerDetailModal = dynamic(
() => import("@/components/(with-side-bar)/topic/sticker/topic-sticker-detail-modal"),
{
ssr: false,
}
);

interface TopicBoardLayoutProps {
children: React.ReactNode;
Expand Down
6 changes: 0 additions & 6 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,6 @@ div.customHandle {
rgba(225, 217, 253, 0.8) 46.63%,
rgba(225, 216, 255, 0) 100%
);
/* background: radial-gradient(
50% 50% at 50% 50%,
#ccbeff 0%,
rgba(204, 190, 255, 0.8) 46.63%,
rgba(225, 216, 255, 0) 100%
); */
}

@utility bg-radial-gradient-duo {
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/(with-side-bar)/layout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import { Suspense } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { usePathname } from "next/navigation";

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

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

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

interface NavItem {
icon: LucideIcon;
Expand Down Expand Up @@ -89,7 +95,9 @@ export default function Sidebar() {
<div className="text-muted-foreground mb-4 text-sm font-semibold uppercase tracking-wider">
나의 토픽
</div>
<RecentTopicList />
<Suspense fallback={<Spinner className="mx-auto" />}>
<RecentTopicList />
</Suspense>
</div>
</aside>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { useGetAllTopics } from "@/lib/tanstack/query/topic";
import { useDashboardStore } from "@/lib/zustand/dashboard";
import type { TopicDTO } from "@/models/topic";

import { Loader2 } from "lucide-react";

import RecentTopicItem from "./recent-topic-item";

export default function RecentTopicList() {
Expand Down Expand Up @@ -39,20 +37,16 @@ export default function RecentTopicList() {

useEffect(() => {
if (!isPending) {
setTotalTopics(recentTopics?.data?.length || 0);
setTotalTopics(recentTopics.data.length);
}
}, [isPending]);
}, [isPending, recentTopics, setTotalTopics]);

return (
<>
{isLoading ? (
<div className="flex items-center justify-center">
<Loader2 className="animate-spin" />
</div>
) : !recentTopics || recentTopics?.data?.length === 0 ? (
{recentTopics.data.length === 0 ? (
<p className="text-muted-foreground text-center text-sm">토픽이 없어요.</p>
) : (
recentTopics?.data?.map((topic) => (
recentTopics.data.map((topic) => (
<RecentTopicItem
key={topic.id}
isSelected={isSelected(topic.id)}
Expand Down
144 changes: 27 additions & 117 deletions apps/web/src/components/(with-side-bar)/topic/add-content-list.tsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,32 @@
"use client";
import { useMemo } from "react";

import { useMemo, useRef, useState } from "react";
import Link from "next/link";

import ResizeBar from "@/components/common/resize-bar";
import { CONTENT_TYPE_OPTIONS, type ContentTypeOptions } from "@/constants/content";
import { useDebounce } from "@/hooks/use-debounce";
import type { ContentTypeOptions } from "@/constants/content";
import { useCreateContent } from "@/lib/tanstack/mutation/topic-content";
import { useGetAllContents } from "@/lib/tanstack/query/topic";
import type { TopicNodeProps } from "@/types/topic";
import { Button, Input } from "@linkyboard/components";
import type { CategoryContentDTO } from "@linkyboard/types";

import { Loader2, Search } from "lucide-react";

import SentinelSpinner from "../../common/sentinel-spinner";
import ContentItem from "../library/content-item";

interface AddContentListProps {
interface ContentListProps {
query: string;
type: ContentTypeOptions;
id: string;
isTopicLoading: boolean;
nodes: TopicNodeProps[];
}

const MIN_WIDTH = 300;
const MAX_WIDTH = 600;

export default function AddContentList({ type, id, isTopicLoading, nodes }: AddContentListProps) {
const [contentPanelWidth, setContentPanelWidth] = useState(300); // Content Panel 기본 너비
const [searchQuery, setSearchQuery] = useState("");

const contentPanelRef = useRef<HTMLDivElement | null>(null);

const debouncedSearchQuery = useDebounce(searchQuery, 300);

export default function AddContentList({ query, id, type }: ContentListProps) {
const {
data: contents,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetAllContents(type);
const { mutateAsync: createContent, isPending } = useCreateContent(id);
const { mutateAsync: createContent } = useCreateContent(id);

const filteredContents = useMemo(() => {
const filteredBySearch = contents?.filter((content) =>
content.title.toLowerCase().includes(debouncedSearchQuery.toLowerCase())
);
return filteredBySearch?.filter(
(content) => !nodes.some((node) => node.data.item.id === content.id)
);
}, [contents, nodes, debouncedSearchQuery]);

const onSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(event.target.value);
};

const onMouseMove = (e: MouseEvent) => {
if (!contentPanelRef.current) return;

const containerRect = contentPanelRef.current.getBoundingClientRect();
const newWidth = e.clientX - containerRect.left;

if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setContentPanelWidth(newWidth);
}
};
return contents.filter((content) => content.title.toLowerCase().includes(query.toLowerCase()));
}, [contents, query]);

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

return (
<>
<div
ref={contentPanelRef}
className="bg-card border-border overflow-hidden rounded-l-lg border border-r-0"
style={{ width: `${contentPanelWidth}px`, minWidth: "300px" }}
>
<div className="relative p-4">
<Search
className="text-muted-foreground absolute left-8 top-1/2 -translate-y-1/2 transform"
size={16}
/>
<Input
type="text"
placeholder="콘텐츠를 검색하세요"
className="pl-10"
value={searchQuery}
onChange={onSearchChange}
/>
</div>
<div className="flex gap-2 px-4 pb-4 shadow">
<Button
variant={type === CONTENT_TYPE_OPTIONS.ALL ? "default" : "outline"}
size="sm"
asChild
>
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.ALL}`}>모두</Link>
</Button>
<Button
variant={type === CONTENT_TYPE_OPTIONS.WEB ? "default" : "outline"}
size="sm"
asChild
>
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.WEB}`}>웹</Link>
</Button>
<Button
variant={type === CONTENT_TYPE_OPTIONS.YOUTUBE ? "default" : "outline"}
size="sm"
asChild
>
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.YOUTUBE}`}>유튜브</Link>
</Button>
</div>
<div className="flex h-[calc(100%-120px)] flex-col gap-3 overflow-y-auto p-4">
{isLoading || isTopicLoading ? (
<div className="flex justify-center">
<Loader2 className="text-muted-foreground animate-spin" />
</div>
) : filteredContents?.length === 0 ? (
<p className="text-muted-foreground text-center">저장된 콘텐츠가 없어요</p>
) : (
filteredContents?.map((item) => (
<ContentItem
draggable
key={`${item.id}-content-item`}
item={item}
onClick={() => onAddContent(item)}
disabled={isPending}
/>
))
)}
<SentinelSpinner
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
<div className="flex h-[calc(100%-120px)] flex-col gap-3 overflow-y-auto p-4">
{filteredContents.length === 0 ? (
<p className="text-muted-foreground text-center">저장된 콘텐츠가 없어요</p>
) : (
filteredContents.map((item) => (
<ContentItem
draggable
key={`${item.id}-content-item`}
item={item}
onClick={() => onAddContent(item)}
/>
</div>
</div>
<ResizeBar onMouseMove={onMouseMove} />
</>
))
)}
<SentinelSpinner
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
/>
</div>
);
}
106 changes: 106 additions & 0 deletions apps/web/src/components/(with-side-bar)/topic/add-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import { Suspense, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";

import ResizeBar from "@/components/common/resize-bar";
import { CONTENT_TYPE_OPTIONS, type ContentTypeOptions } from "@/constants/content";
import { useDebounce } from "@/hooks/use-debounce";
import { Button, Input, Spinner } from "@linkyboard/components";

import { Search } from "lucide-react";

const AddContentList = dynamic(() => import("./add-content-list"), {
ssr: false,
loading: () => (
<div className="flex justify-center py-2">
<Spinner />
</div>
),
});

interface AddContentListProps {
type: ContentTypeOptions;
id: string;
}

const MIN_WIDTH = 300;
const MAX_WIDTH = 600;

export default function AddContent({ type, id }: AddContentListProps) {
const [contentPanelWidth, setContentPanelWidth] = useState(300); // Content Panel 기본 너비
const [searchQuery, setSearchQuery] = useState("");

const contentPanelRef = useRef<HTMLDivElement | null>(null);

const debouncedSearchQuery = useDebounce(searchQuery, 300);

const onMouseMove = (e: MouseEvent) => {
if (!contentPanelRef.current) return;

const containerRect = contentPanelRef.current.getBoundingClientRect();
const newWidth = e.clientX - containerRect.left;

if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setContentPanelWidth(newWidth);
}
};

return (
<>
<div
ref={contentPanelRef}
className="bg-card border-border overflow-hidden rounded-l-lg border border-r-0"
style={{ width: `${contentPanelWidth}px`, minWidth: "300px" }}
>
<div className="relative p-4">
<Search
className="text-muted-foreground absolute left-8 top-1/2 -translate-y-1/2 transform"
size={16}
/>
<Input
type="text"
placeholder="콘텐츠를 검색하세요"
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-2 px-4 pb-4 shadow">
<Button
variant={type === CONTENT_TYPE_OPTIONS.ALL ? "default" : "outline"}
size="sm"
asChild
>
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.ALL}`}>모두</Link>
</Button>
<Button
variant={type === CONTENT_TYPE_OPTIONS.WEB ? "default" : "outline"}
size="sm"
asChild
>
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.WEB}`}>웹</Link>
</Button>
<Button
variant={type === CONTENT_TYPE_OPTIONS.YOUTUBE ? "default" : "outline"}
size="sm"
asChild
>
<Link href={`/topic/${id}?type=${CONTENT_TYPE_OPTIONS.YOUTUBE}`}>유튜브</Link>
</Button>
</div>
<Suspense
fallback={
<div className="flex justify-center py-2">
<Spinner />
</div>
}
>
<AddContentList query={debouncedSearchQuery} id={id} type={type} />
</Suspense>
</div>
<ResizeBar onMouseMove={onMouseMove} />
</>
);
}
Loading