Skip to content

Commit bcdd6a8

Browse files
authored
feat: prefetch & ssr pages (#310-prefetch) (#312)
1 parent 7412e5d commit bcdd6a8

36 files changed

+869
-304
lines changed

packages/utils/src/object.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@ export const objectToQueryString = (params: ObjectQueryParams): string => {
3737
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
3838
.join('&');
3939
};
40+
41+
export const queryStringToObject = (queryString: string): Record<string, string> => {
42+
const query = queryString.startsWith('?') ? queryString.slice(1) : queryString;
43+
44+
if (!query) return {};
45+
46+
return query.split('&').reduce((params: Record<string, string>, param) => {
47+
const [key, value] = param.split('=').map(decodeURIComponent);
48+
if (key) {
49+
params[key] = value || '';
50+
}
51+
return params;
52+
}, {});
53+
};

services/one-app/next.config.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,30 @@ const nextConfig = {
55
bodySizeLimit: '10mb',
66
},
77
turbo: {
8-
rules: {},
8+
rules: {
9+
'*.svg': {
10+
loaders: ['@svgr/webpack'],
11+
as: '*.js',
12+
},
13+
},
914
},
1015
},
16+
webpack(config: any) {
17+
config.module.rules.push({
18+
test: /\.svg$/,
19+
use: [
20+
{
21+
loader: '@svgr/webpack',
22+
options: {
23+
svgo: true,
24+
typescript: true,
25+
},
26+
},
27+
],
28+
});
29+
30+
return config;
31+
},
1132
};
1233

1334
export default nextConfig;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use client';
2+
3+
import { useQuery } from '@tanstack/react-query';
4+
5+
import { formatDateTime } from '@ahhachul/utils';
6+
7+
import { ReadonlyEditor } from '@/component/Editor';
8+
// import { SUBWAY_LOGO_SVG_LIST } from '@/component';
9+
import { TIMESTAMP } from '@/constant';
10+
import { cn, isLexicalContent } from '@/util';
11+
12+
import { CommunityTypeBadge } from './CommunityTypeBadge';
13+
14+
import { getCommunityDetailPost } from '../_lib/getDetailPost';
15+
16+
type Props = {
17+
id: number;
18+
};
19+
20+
export default function CommunityPostDetail({ id }: Props) {
21+
const { data: post } = useQuery({
22+
queryKey: ['community-post', id],
23+
queryFn: getCommunityDetailPost,
24+
staleTime: 5 * TIMESTAMP.MINUTE,
25+
select: res => res.result,
26+
});
27+
28+
if (!post) return null;
29+
30+
// const images = post.isFromLost112
31+
// ? [
32+
// {
33+
// imageId: getRandomInt(),
34+
// imageUrl: post.externalSourceImageUrl,
35+
// },
36+
// ]
37+
// : post.images;
38+
39+
return (
40+
<>
41+
<article>
42+
{/* <BaseArticleImages label={post.title} images={images} /> */}
43+
<div className=" pt-5 px-5">
44+
<CommunityTypeBadge communityType={post.categoryType} />
45+
<div className=" text-title-large text-gray-90 line-clamp-2 pt-[13px] pb-4">
46+
{post.title}
47+
</div>
48+
<div className=" w-full flex items-center justify-between pb-4 border-b border-b-gray-20">
49+
<div className=" flex items-center gap-1 text-body-medium">
50+
<span className=" text-gray-80">{post.writer || '로스트 112'}</span>
51+
<span className=" text-gray-70">{formatDateTime(post.createdAt!)}</span>
52+
</div>
53+
<div className=" flex items-center text-gray-90 text-label-medium font-regular">
54+
{/* {SUBWAY_LOGO_SVG_LIST[post.subwayLineId]} */}
55+
</div>
56+
</div>
57+
</div>
58+
59+
<div className=" px-5">
60+
{isLexicalContent(post.content) ? (
61+
<ReadonlyEditor
62+
content={post.content}
63+
className={cn('px-0', 'py-6', '[&>div>div]:p-0', '[&>div>div]:border-none')}
64+
/>
65+
) : (
66+
<p className=" py-6 mb-3 text-body-large-semi text-gray-90">{post.content}</p>
67+
)}
68+
</div>
69+
</article>
70+
71+
{/* <LostFoundCommentList commentCnt={post.commentCnt} articleId={lostId} /> */}
72+
{/* <CommentTextField
73+
placeholder={`${post.writer ?? '로스트 112'}에게 댓글을 남겨주세요.`}
74+
onSubmit={handleSubmitComment}
75+
onChange={handleChangeComment}
76+
/> */}
77+
</>
78+
);
79+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client';
2+
3+
import { CommunityType } from '@/types/community';
4+
5+
interface Props {
6+
communityType: CommunityType;
7+
}
8+
9+
export const CommunityTypeBadge = ({ communityType }: Props) => {
10+
return (
11+
<div className=" h-7 text-label-small text-gray-0 px-2.5 flex items-center justify-center bg-[#407AD6] rounded-[100px] w-max">
12+
{communityType === CommunityType.FREE
13+
? '자유'
14+
: communityType === CommunityType.HUMOR
15+
? '유머'
16+
: '정보'}
17+
</div>
18+
);
19+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
5+
import { formatDateTime } from '@ahhachul/utils';
6+
7+
import type { LostFoundPostDetail } from '@/types';
8+
9+
interface Props {
10+
post: LostFoundPostDetail;
11+
}
12+
13+
export const Lost112ArticleTable = ({ post }: Props) => {
14+
return (
15+
<div className="max-w bg-gray-20 px-5 py-4">
16+
<div className="bg-gray-0 rounded-lg px-5 py-4">
17+
<h2 className="text-gray-80 text-label-medium pb-4 border-b border-b-gray-20">
18+
유실물 상세정보
19+
</h2>
20+
21+
<div className="grid grid-cols-[120px,1fr] gap-y-4 text-sm py-3 px-[5px]">
22+
<div className="text-gray-80 text-label-medium">습득일</div>
23+
<div className="text-gray-90 text-label-medium">{formatDateTime(post.createdAt)}</div>
24+
25+
{post?.storage && (
26+
<>
27+
<div className="text-gray-80 text-label-medium">습득장소</div>
28+
<div className="text-gray-90 text-label-medium">{post.storage}</div>
29+
</>
30+
)}
31+
32+
{post?.categoryName && (
33+
<>
34+
<div className="text-gray-80 text-label-medium">물품분류</div>
35+
<div className="text-gray-90 text-label-medium">{post.categoryName}</div>
36+
</>
37+
)}
38+
39+
{post?.storageNumber && (
40+
<>
41+
<div className="text-gray-80 text-label-medium">보관 장소 전화번호</div>
42+
<div className="text-gray-90 text-label-medium">{post.storageNumber}</div>
43+
</>
44+
)}
45+
46+
{post?.storage && (
47+
<>
48+
<div className="text-gray-80 text-label-medium">보관장소</div>
49+
<div className="text-gray-90 text-label-medium">{post.storage}</div>
50+
</>
51+
)}
52+
53+
{post?.pageUrl && (
54+
<>
55+
<div className="text-gray-80 text-label-medium">원본 게시글</div>
56+
<Link
57+
href={post.pageUrl}
58+
target="_blank"
59+
rel="noopener noreferrer"
60+
className="text-blue-700 text-label-medium"
61+
>
62+
바로가기
63+
</Link>
64+
</>
65+
)}
66+
</div>
67+
68+
<div className="mt-6 flex items-center gap-2 text-green-600 justify-center">
69+
<div className="w-2 h-2 rounded-full bg-green-600 "></div>
70+
<span className="text-sm">
71+
{post.status === 'PROGRESS' ? '현재 보관중 입니다.' : '찾기 완료!'}
72+
</span>
73+
</div>
74+
</div>
75+
</div>
76+
);
77+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client';
2+
3+
import { ChevronIcon } from '@/asset/icon';
4+
import { RecommendArticleCard } from '@/component';
5+
import { IRecommendPost } from '@/types';
6+
7+
interface Props {
8+
posts: IRecommendPost[];
9+
}
10+
11+
export const RecommendArticles = ({ posts }: Props) => {
12+
if (!posts?.length) return null;
13+
14+
return (
15+
<section>
16+
<div className=" h-12 pl-5 flex items-center border-b border-b-gray-30">
17+
<span className=" text-gray-90 text-title-large">추천 습득물</span>
18+
<ChevronIcon />
19+
</div>
20+
{posts.map(post => (
21+
<RecommendArticleCard key={post.id} post={post} />
22+
))}
23+
</section>
24+
);
25+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { QueryFunction } from '@tanstack/react-query';
2+
3+
import type { IResponse } from '@/types';
4+
import type { CommunityDetail } from '@/types/community';
5+
6+
export const getCommunityDetailPost: QueryFunction<
7+
IResponse<CommunityDetail>,
8+
[_1: string, id: number]
9+
> = async ({ queryKey }) => {
10+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
11+
const [_1, id] = queryKey;
12+
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/community-posts/${id}`, {
13+
next: {
14+
tags: ['lcommunity-post', id.toString()],
15+
},
16+
credentials: 'include',
17+
});
18+
19+
if (!res.ok) {
20+
// This will activate the closest `error.js` Error Boundary
21+
throw new Error('Failed to fetch data');
22+
}
23+
24+
return res.json();
25+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { cookies } from 'next/headers';
2+
3+
export const getCommunityDetailPostServer = async ({
4+
queryKey,
5+
}: {
6+
queryKey: [string, number];
7+
}) => {
8+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
9+
const [_1, id] = queryKey;
10+
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/community-posts/${id}`, {
11+
next: {
12+
revalidate: 3600,
13+
tags: ['community-post', id.toString()],
14+
},
15+
credentials: 'include',
16+
headers: { Cookie: (await cookies()).toString() },
17+
});
18+
19+
if (!res.ok) {
20+
// This will activate the closest `error.js` Error Boundary
21+
throw new Error('Failed to fetch data');
22+
}
23+
24+
return res.json();
25+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
2+
import type { Metadata } from 'next';
3+
4+
import CommunityPostDetail from './_components/CommunityDetail';
5+
import { getCommunityDetailPostServer } from './_lib/getDetailPostServer';
6+
7+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
8+
const { id } = await params;
9+
const post = await getCommunityDetailPostServer({ queryKey: ['community-post', id] });
10+
11+
return {
12+
title: `지하철 커뮤니티 아하철 / ${post.result.title}`,
13+
description: post.result.content,
14+
};
15+
}
16+
17+
type Props = {
18+
params: Promise<{
19+
id: number;
20+
}>;
21+
};
22+
23+
export default async function CommunityDetailPage(props: Props) {
24+
const { id } = await props.params;
25+
const queryClient = new QueryClient();
26+
await queryClient.prefetchQuery({
27+
queryKey: ['community-post', id],
28+
queryFn: getCommunityDetailPostServer,
29+
});
30+
const dehydratedState = dehydrate(queryClient);
31+
32+
return (
33+
<main className="flex min-h-screen flex-col text-black bg-white mb-[210px]">
34+
<HydrationBoundary state={dehydratedState}>
35+
<CommunityPostDetail id={id} />
36+
</HydrationBoundary>
37+
</main>
38+
);
39+
}

0 commit comments

Comments
 (0)