Skip to content

Commit 5b5b13e

Browse files
committed
feat: cloud migration
1 parent 36f00fa commit 5b5b13e

27 files changed

Lines changed: 588 additions & 446 deletions

.github/workflows/deploy.yml

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
uses: actions/setup-node@v4
3131
with:
3232
node-version: 20
33-
cache: 'pnpm'
33+
cache: "pnpm"
3434

3535
- name: Install dependencies
3636
run: pnpm install
@@ -46,8 +46,15 @@ jobs:
4646
IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/blog
4747
IMAGE_TAG=$(date +%Y%m%d%H%M%S)
4848
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
49-
docker build -t $IMAGE_NAME:$IMAGE_TAG .
50-
docker push $IMAGE_NAME:$IMAGE_TAG
49+
50+
printf "%s" "${{ secrets.SECRET_KEY }}" > secret_key.txt
51+
52+
docker buildx build \
53+
--secret id=SECRET_KEY,src=secret_key.txt \
54+
-t $IMAGE_NAME:$IMAGE_TAG \
55+
--push .
56+
57+
rm -f secret_key.txt
5158
5259
- name: Checkout GitOps repo
5360
uses: actions/checkout@v4
@@ -69,9 +76,9 @@ jobs:
6976
with:
7077
token: ${{ secrets.GITOPS_REPO_ACCESS }}
7178
path: gitops
72-
commit-message: 'chore: update blog image tag to ${{ env.IMAGE_TAG }}'
79+
commit-message: "chore: update blog image tag to ${{ env.IMAGE_TAG }}"
7380
branch: update/image-${{ env.IMAGE_TAG }}
74-
title: 'Update blog image tag to ${{ env.IMAGE_TAG }}'
81+
title: "Update blog image tag to ${{ env.IMAGE_TAG }}"
7582
body: |
7683
This PR updates the image tag in \`blog-deploy.yaml\` to \`${{ env.IMAGE_TAG }}\`.
7784

Dockerfile

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ FROM node:20-alpine AS deps
22
WORKDIR /app
33

44
RUN corepack enable
5-
65
COPY package.json pnpm-lock.yaml ./
7-
86
RUN pnpm install --frozen-lockfile
97

108
FROM node:20-alpine AS builder
@@ -17,7 +15,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
1715
COPY --from=deps /app/node_modules ./node_modules
1816
COPY . .
1917

20-
RUN pnpm build
18+
RUN --mount=type=secret,id=SECRET_KEY \
19+
export SECRET_KEY="$(cat /run/secrets/SECRET_KEY)" && \
20+
pnpm build
2121

2222
FROM node:20-alpine AS runner
2323
WORKDIR /app
@@ -34,5 +34,4 @@ COPY --from=builder /app/public ./public
3434

3535
USER nextjs
3636
EXPOSE 3000
37-
3837
CMD ["node", "server.js"]

app/api/search/route.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,78 @@
11
import { NextResponse } from "next/server";
22

3-
import { searchPosts } from "@/apis/search";
4-
import { SearchResponse } from "@/interfaces/search";
3+
import { SearchResponse, SearchResult } from "@/interfaces/search";
4+
import { blogClient } from "@/lib/api-client";
55

66
const MIN_QUERY_LENGTH = 1;
77
const DEFAULT_LIMIT = 20;
88
const MAX_LIMIT = 50;
9+
const DEFAULT_SNIPPET_LENGTH = 180;
10+
11+
function toStringValue(value: unknown) {
12+
return typeof value === "string" ? value : "";
13+
}
14+
15+
function toNumberValue(value: unknown, fallback = 0) {
16+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
17+
}
18+
19+
function toStringArray(value: unknown) {
20+
if (!Array.isArray(value)) {
21+
return [];
22+
}
23+
24+
return value.filter((item): item is string => typeof item === "string");
25+
}
26+
27+
function getMinRead(content: string, fallback = 1) {
28+
if (!content.trim()) {
29+
return fallback;
30+
}
31+
32+
const wordsPerMinute = 200;
33+
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
34+
return Math.max(1, Math.round(wordCount / wordsPerMinute));
35+
}
36+
37+
function getSnippetFromContent(content: string, fallback: string) {
38+
const baseText = content.trim() || fallback;
39+
if (!baseText) {
40+
return "";
41+
}
42+
43+
return baseText.slice(0, DEFAULT_SNIPPET_LENGTH);
44+
}
45+
46+
function normalizeSearchResults(rawResults: unknown[]): SearchResult[] {
47+
return rawResults.map((raw, index) => {
48+
const item = (raw ?? {}) as Record<string, unknown>;
49+
const title = toStringValue(item.title);
50+
const description = toStringValue(item.description);
51+
const content = toStringValue(item.content);
52+
const date =
53+
toStringValue(item.date) ||
54+
toStringValue(item.createdAt) ||
55+
new Date().toISOString();
56+
57+
return {
58+
id: toNumberValue(item.id, index + 1),
59+
title,
60+
description,
61+
category: toStringValue(item.category),
62+
slug: toStringValue(item.slug),
63+
date,
64+
minRead: toNumberValue(item.minRead, getMinRead(content)),
65+
tags: toStringArray(item.tags),
66+
snippet:
67+
toStringValue(item.snippet) ||
68+
getSnippetFromContent(content, description),
69+
matchedFields: toStringArray(item.matchedFields).filter(
70+
(field): field is SearchResult["matchedFields"][number] =>
71+
field === "title" || field === "tags" || field === "body",
72+
),
73+
};
74+
});
75+
}
976

1077
function getLimit(limitQuery: string | null) {
1178
if (!limitQuery) {
@@ -36,10 +103,12 @@ export async function GET(request: Request) {
36103
return NextResponse.json(emptyResponse);
37104
}
38105

39-
const results = searchPosts(query, limit);
106+
const upstream = await blogClient.search({ q: query, limit });
107+
const rawResults = Array.isArray(upstream.results) ? upstream.results : [];
108+
const results = normalizeSearchResults(rawResults);
40109
const response: SearchResponse = {
41-
query,
42-
total: results.length,
110+
query: toStringValue(upstream.query) || query,
111+
total: toNumberValue(upstream.total, results.length),
43112
results,
44113
};
45114

app/api/views/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { blogClient } from "@/lib/api-client";
2+
3+
export async function GET(request: Request) {
4+
const { searchParams } = new URL(request.url);
5+
const slug = searchParams.get("slug");
6+
const category = searchParams.get("category");
7+
8+
if (!slug || !category) {
9+
return new Response("Slug and category are required", { status: 400 });
10+
}
11+
12+
await blogClient.incrementView(category, slug);
13+
14+
return new Response("View incremented", { status: 200 });
15+
}

app/category/[slug]/_components/category-post-item.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function CategoryPostItem({
1515
}) {
1616
return (
1717
<motion.div
18-
className="group flex cursor-pointer flex-col gap-3 rounded-xl p-3 transition-all duration-200 hover:bg-neutral-50 dark:hover:bg-neutral-800 sm:flex-row sm:gap-5"
18+
className="group flex cursor-pointer flex-col gap-3 rounded-xl p-3 transition-all duration-200 hover:bg-neutral-50 sm:flex-row sm:gap-5 dark:hover:bg-neutral-800"
1919
initial={{ opacity: 0, x: 20 }}
2020
animate={{ opacity: 1, x: 0 }}
2121
transition={{ delay }}
@@ -25,14 +25,15 @@ export function CategoryPostItem({
2525
src={post.thumbnail}
2626
alt={post.title}
2727
fill
28-
blurDataURL={post.thumbnail.blurDataURL}
2928
className="object-cover"
30-
placeholder="blur"
29+
placeholder={post.thumbnailBlurDataURL ? "blur" : "empty"}
30+
blurDataURL={post.thumbnailBlurDataURL}
31+
unoptimized
3132
/>
3233
</div>
3334
<div className="flex flex-1 flex-col justify-between py-1">
3435
<div>
35-
<h3 className="mb-1 line-clamp-2 text-sm font-semibold text-neutral-800 group-hover:text-neutral-950 dark:text-neutral-200 dark:group-hover:text-neutral-50 sm:text-base">
36+
<h3 className="mb-1 line-clamp-2 text-sm font-semibold text-neutral-800 group-hover:text-neutral-950 sm:text-base dark:text-neutral-200 dark:group-hover:text-neutral-50">
3637
{post.title}
3738
</h3>
3839
<p className="text-muted-foreground line-clamp-2 text-sm">

app/category/[slug]/page.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,24 @@ interface CategoryPageProps {
1616
export const dynamicParams = false;
1717
export const dynamic = "force-static";
1818

19-
export function generateStaticParams() {
20-
const categories = getCategories();
19+
export async function generateStaticParams() {
20+
const categories = await getCategories();
2121
return categories.map((slug) => ({ slug }));
2222
}
2323

2424
export async function generateMetadata({
2525
params,
2626
}: CategoryPageProps): Promise<Metadata> {
2727
const { slug } = await params;
28-
const categories = getCategories();
28+
const categories = await getCategories();
2929

3030
if (!categories.includes(slug)) {
3131
return {
3232
description: `${slug} Category`,
3333
};
3434
}
3535

36-
const postCount = getPostsByCategory(slug).length;
36+
const postCount = (await getPostsByCategory(slug)).length;
3737
const title = `${slug}`;
3838
const description = `${slug} 카테고리의 게시물 수: ${postCount}개`;
3939

@@ -57,13 +57,13 @@ export async function generateMetadata({
5757

5858
export default async function CategoryPage({ params }: CategoryPageProps) {
5959
const { slug } = await params;
60-
const categories = getCategories();
60+
const categories = await getCategories();
6161

6262
if (!categories.includes(slug)) {
6363
notFound();
6464
}
6565

66-
const posts = getPostsByCategory(slug);
66+
const posts = await getPostsByCategory(slug);
6767

6868
return (
6969
<div className="py-8 sm:py-10">

app/category/page.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ export const metadata: Metadata = {
1212
},
1313
};
1414

15-
export default function CategoryIndexPage() {
16-
const categories = getCategories();
15+
export default async function CategoryIndexPage() {
16+
const categories = await getCategories();
1717

18-
const categorySummaries = categories.map((category) => ({
19-
category,
20-
count: getPostsByCategory(category).length,
21-
}));
18+
const categorySummaries = await Promise.all(
19+
categories.map(async (category) => ({
20+
category,
21+
count: (await getPostsByCategory(category)).length,
22+
})),
23+
);
2224

2325
return (
2426
<div className="py-10">

app/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default async function RootLayout({
5252
}: Readonly<{
5353
children: React.ReactNode;
5454
}>) {
55-
const categories = getCategories();
55+
const categories = await getCategories();
5656

5757
return (
5858
<html lang="en">
@@ -66,7 +66,7 @@ export default async function RootLayout({
6666
</div>
6767
<div
6868
id="content-wrapper"
69-
className="flex-1 overflow-x-hidden px-4 pb-24 md:pb-0 md:pl-84 lg:pr-0"
69+
className="flex-1 overflow-hidden px-4 pb-24 md:pb-0 md:pl-84 lg:pr-0"
7070
>
7171
<ScrollRestoration />
7272
{children}

app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const metadata: Metadata = {
1818
};
1919

2020
export default async function Home() {
21-
const posts = getAllPosts();
21+
const posts = await getAllPosts();
2222
const recentPosts = posts.slice(0, 5);
2323
const groupedPosts = groupBy(posts, (post) => post.category);
2424
const orderedByRecent = Object.entries(groupedPosts).map(

app/post/[category]/[slug]/page.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ const getOpenGraphImages = (thumbnailSrc?: string) => {
2727
return images;
2828
};
2929

30-
export function generateStaticParams() {
31-
const posts = getAllPosts();
30+
export async function generateStaticParams() {
31+
const posts = await getAllPosts();
3232
return posts.map((post) => ({
3333
category: post.category,
3434
slug: post.slug,
@@ -39,7 +39,7 @@ export async function generateMetadata({
3939
params,
4040
}: PostPageProps): Promise<Metadata> {
4141
const { category, slug } = await params;
42-
const categories = getCategories();
42+
const categories = await getCategories();
4343

4444
if (!categories.includes(category)) {
4545
return {
@@ -53,9 +53,9 @@ export async function generateMetadata({
5353
}
5454

5555
try {
56-
const post = getPost(category, slug, { withContent: false });
56+
const post = await getPost(category, slug, { withContent: false });
5757
const canonical = `/post/${category}/${slug}`;
58-
const images = getOpenGraphImages(post.thumbnail?.src);
58+
const images = getOpenGraphImages(post.thumbnail);
5959

6060
return {
6161
title: post.title,
@@ -93,14 +93,14 @@ export async function generateMetadata({
9393

9494
export default async function PostPage({ params }: PostPageProps) {
9595
const { category, slug } = await params;
96-
const categories = getCategories();
96+
const categories = await getCategories();
9797

9898
if (!categories.includes(category)) {
9999
notFound();
100100
}
101101

102-
const post = getPost(category, slug, { withContent: true });
103-
const categoryPosts = getPostsByCategory(category);
102+
const post = await getPost(category, slug, { withContent: true });
103+
const categoryPosts = await getPostsByCategory(category);
104104

105105
const currentIndex = categoryPosts.findIndex((p) => p.slug === slug);
106106
const start = Math.max(0, currentIndex - 3);

0 commit comments

Comments
 (0)