diff --git a/app/api/series/public/route.ts b/app/api/series/public/route.ts new file mode 100644 index 0000000..a0711f2 --- /dev/null +++ b/app/api/series/public/route.ts @@ -0,0 +1,19 @@ +import { getPublicSeriesPage } from '@/app/lib/getPublicSeriesPage'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request) { + try { + const cursor = new URL(request.url).searchParams.get('cursor') || undefined; + const page = await getPublicSeriesPage(cursor); + return Response.json(page, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + if (error instanceof Error && error.message === 'Invalid series cursor') { + return Response.json({ error: 'Invalid cursor' }, { status: 400 }); + } + console.error('시리즈 목록 조회 실패', error); + return Response.json({ error: '시리즈 목록 조회 실패' }, { status: 500 }); + } +} diff --git a/app/api/series/route.ts b/app/api/series/route.ts index e82bbfa..074f61a 100644 --- a/app/api/series/route.ts +++ b/app/api/series/route.ts @@ -53,7 +53,7 @@ export async function GET(request: Request) { return NextResponse.json(series, { status: 200, headers: { - 'Cache-Control': 'public, max-age=30, s-maxage=30', + 'Cache-Control': 'no-store', }, }); } catch (error: any) { diff --git a/app/entities/profile/AboutMe.tsx b/app/entities/profile/AboutMe.tsx index 5b73c44..a994d03 100644 --- a/app/entities/profile/AboutMe.tsx +++ b/app/entities/profile/AboutMe.tsx @@ -5,8 +5,8 @@ import { githubLink, linkedinLink } from '@/app/lib/constants/landingPageData'; const AboutMe = () => { return (
-
-
+
+
{ className="absolute inset-0 object-cover w-full h-full bg-gray-500 transition-opacity duration-700 group-hover/duck:opacity-0" />
-
-
+
+

저는 커피☕와 사진 📸을 좋아하는 개발자입니다~

-
+

About Me diff --git a/app/entities/profile/Experience.tsx b/app/entities/profile/Experience.tsx index 700f75f..ee682ae 100644 --- a/app/entities/profile/Experience.tsx +++ b/app/entities/profile/Experience.tsx @@ -35,9 +35,9 @@ const Experience = () => { {experiences.map((exp) => (
-
+
{exp.type === 'work' ? ( ) : ( @@ -45,7 +45,7 @@ const Experience = () => { )}
-
+

{exp.company}

@@ -59,7 +59,7 @@ const Experience = () => { {exp.role}

- + {exp.period}
diff --git a/app/entities/series/list/SeriesList.tsx b/app/entities/series/list/SeriesList.tsx deleted file mode 100644 index 7631dc2..0000000 --- a/app/entities/series/list/SeriesList.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; -import { FaBookOpen } from 'react-icons/fa'; -import SeriesGridSkeleton from '@/app/entities/common/Skeleton/SeriesGridSkeleton'; -import { getAllSeriesData } from '@/app/entities/series/api/series'; -import SeriesPreview from '@/app/entities/series/list/SeriesPreview'; -import useGridColumns from '@/app/hooks/common/useGridColumns'; -import { Series } from '@/app/types/Series'; - -const SeriesList = () => { - const [series, setSeries] = useState([]); - const [loading, setLoading] = useState(true); - const cols = useGridColumns(); - - useEffect(() => { - const getSeries = async () => { - const data = await getAllSeriesData(); - setSeries(data); - setLoading(false); - }; - - getSeries(); - }, []); - - if (loading) return ; - if (!loading && series.length === 0) return ; - - return ( -
    - {series.map((item, index) => { - const row = Math.floor(index / cols); - const col = index % cols; - const diagonalIndex = row + col; - const delay = diagonalIndex * 0.1; - - return ( -
  • - -
  • - ); - })} -
- ); -}; - -const NoSeriesFound = () => { - return ( -
- -

No Series Found

-
- ); -}; - -export default SeriesList; diff --git a/app/entities/series/list/SeriesListEntry.tsx b/app/entities/series/list/SeriesListEntry.tsx new file mode 100644 index 0000000..5aff243 --- /dev/null +++ b/app/entities/series/list/SeriesListEntry.tsx @@ -0,0 +1,21 @@ +import SeriesPreview from '@/app/entities/series/list/SeriesPreview'; +import type { SeriesListItem } from '@/app/types/Series.d'; + +interface SeriesListEntryProps { + item: SeriesListItem; + index: number; +} + +export default function SeriesListEntry({ item, index }: SeriesListEntryProps) { + return ( +
  • + +
  • + ); +} diff --git a/app/entities/series/list/SeriesLoadMore.tsx b/app/entities/series/list/SeriesLoadMore.tsx new file mode 100644 index 0000000..b65c8f6 --- /dev/null +++ b/app/entities/series/list/SeriesLoadMore.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import SeriesListEntry from '@/app/entities/series/list/SeriesListEntry'; +import type { SeriesListItem, SeriesPage } from '@/app/types/Series.d'; + +interface SeriesLoadMoreProps { + initialCursor: string; + initialCount: number; + initialIds: string[]; +} + +export default function SeriesLoadMore({ + initialCursor, + initialCount, + initialIds, +}: SeriesLoadMoreProps) { + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(initialCursor); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const sentinelRef = useRef(null); + const inFlightRef = useRef(false); + const loadedIdsRef = useRef(new Set(initialIds)); + + const loadMore = useCallback(async () => { + if (!cursor || inFlightRef.current) return; + inFlightRef.current = true; + setLoading(true); + setError(false); + + try { + const response = await fetch( + `/api/series/public?cursor=${encodeURIComponent(cursor)}`, + { + cache: 'no-store', + } + ); + if (!response.ok) + throw new Error(`Series request failed: ${response.status}`); + + const page = (await response.json()) as SeriesPage; + const newItems = page.items.filter((item) => { + if (loadedIdsRef.current.has(item._id)) return false; + loadedIdsRef.current.add(item._id); + return true; + }); + setItems((previous) => [...previous, ...newItems]); + setCursor(page.nextCursor); + } catch (cause) { + console.error('시리즈 목록 추가 조회 실패', cause); + setError(true); + } finally { + inFlightRef.current = false; + setLoading(false); + } + }, [cursor]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !cursor || error) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) void loadMore(); + }, + { rootMargin: '400px' } + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [cursor, error, loadMore]); + + return ( + <> + {items.map((item, index) => ( + + ))} + {cursor && ( +
  • + {error ? ( + + ) : ( + + {loading ? '시리즈를 불러오는 중...' : '더 많은 시리즈 불러오기'} + + )} +
  • + )} + + ); +} diff --git a/app/entities/series/list/SeriesPreview.tsx b/app/entities/series/list/SeriesPreview.tsx index cc75c9f..5d910b4 100644 --- a/app/entities/series/list/SeriesPreview.tsx +++ b/app/entities/series/list/SeriesPreview.tsx @@ -1,11 +1,10 @@ import Image from 'next/image'; import Link from 'next/link'; -import React from 'react'; import { FaBookOpen, FaCalendar } from 'react-icons/fa'; -import { Series } from '@/app/types/Series'; +import type { SeriesListItem } from '@/app/types/Series.d'; interface SeriesPreviewProps { - item: Series; + item: SeriesListItem; } const SeriesPreview = ({ item }: SeriesPreviewProps) => { @@ -30,6 +29,7 @@ const SeriesPreview = ({ item }: SeriesPreviewProps) => { src={item.thumbnailImage} alt={item.title} loading={'lazy'} + sizes="(max-width: 767px) 100vw, (max-width: 1023px) 50vw, 320px" className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-200" /> ) : ( @@ -47,11 +47,13 @@ const SeriesPreview = ({ item }: SeriesPreviewProps) => {
    - {new Date(item.date).toLocaleDateString()} + {new Date(item.date).toLocaleDateString('ko-KR', { + timeZone: 'Asia/Seoul', + })} - {item.posts.length || 0} posts + {item.postCount} posts
    diff --git a/app/entities/series/list/__test__/SeriesLoadMore.spec.tsx b/app/entities/series/list/__test__/SeriesLoadMore.spec.tsx new file mode 100644 index 0000000..b363996 --- /dev/null +++ b/app/entities/series/list/__test__/SeriesLoadMore.spec.tsx @@ -0,0 +1,59 @@ +import { act, render, screen } from '@testing-library/react'; +import SeriesLoadMore from '../SeriesLoadMore'; + +jest.mock('../SeriesListEntry', () => ({ + __esModule: true, + default: ({ item }: { item: { title: string } }) =>
  • {item.title}
  • , +})); + +let onIntersect: IntersectionObserverCallback; + +class MockIntersectionObserver { + constructor(callback: IntersectionObserverCallback) { + onIntersect = callback; + } + observe = jest.fn(); + disconnect = jest.fn(); + unobserve = jest.fn(); +} + +beforeEach(() => { + global.IntersectionObserver = + MockIntersectionObserver as unknown as typeof IntersectionObserver; + global.fetch = jest.fn(); +}); + +it('loads the next page once and removes IDs already shown in the server page', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + items: [ + { _id: 'initial', title: 'Duplicate' }, + { _id: 'next', title: 'Next series' }, + ], + nextCursor: null, + }), + }); + + render( +
      + +
    + ); + + await act(async () => { + onIntersect( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); + + expect(await screen.findByText('Next series')).toBeTruthy(); + expect(screen.queryByText('Duplicate')).toBeNull(); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(screen.queryByText('더 많은 시리즈 불러오기')).toBeNull(); +}); diff --git a/app/lib/__test__/seriesPagination.spec.ts b/app/lib/__test__/seriesPagination.spec.ts new file mode 100644 index 0000000..5ce12d9 --- /dev/null +++ b/app/lib/__test__/seriesPagination.spec.ts @@ -0,0 +1,103 @@ +/** @jest-environment node */ + +import { Types } from 'mongoose'; +import { + getPublicSeriesPage, + SERIES_PAGE_SIZE, +} from '@/app/lib/getPublicSeriesPage'; +import { + decodeSeriesCursor, + encodeSeriesCursor, + seriesCursorFilter, +} from '@/app/lib/seriesCursor'; +import Series from '@/app/models/Series'; + +jest.mock('../dbConnect', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../models/Series', () => ({ + __esModule: true, + default: { aggregate: jest.fn() }, +})); + +const aggregate = Series.aggregate as jest.Mock; + +describe('series cursor', () => { + it('round-trips a tied sort position and rejects malformed input', () => { + const cursor = { + sortOrder: 2, + date: 1710000000000, + id: new Types.ObjectId('665000000000000000000001'), + }; + + expect(decodeSeriesCursor(encodeSeriesCursor(cursor))).toEqual(cursor); + expect(decodeSeriesCursor('invalid')).toBeNull(); + expect( + decodeSeriesCursor( + Buffer.from(JSON.stringify([2, 123, 'bad'])).toString('base64url') + ) + ).toBeNull(); + }); + + it('advances through missing sort orders before numbered sort orders', () => { + const id = new Types.ObjectId('665000000000000000000001'); + expect(seriesCursorFilter({ sortOrder: null, date: 100, id })).toEqual({ + $or: [ + { sortOrder: { $ne: null } }, + { sortOrder: null, date: { $lt: 100 } }, + { sortOrder: null, date: 100, _id: { $gt: id } }, + ], + }); + }); +}); + +describe('public series page', () => { + beforeEach(() => aggregate.mockReset()); + + it('returns one page, a next cursor, and only card fields', async () => { + aggregate.mockResolvedValue( + Array.from({ length: SERIES_PAGE_SIZE + 1 }, (_, index) => ({ + _id: new Types.ObjectId( + `6650000000000000000000${index.toString(16).padStart(2, '0')}` + ), + slug: `series-${index}`, + title: `Series ${index}`, + description: '', + date: 100 - index, + thumbnailImage: '', + sortOrder: 0, + postCount: index, + })) + ); + + const page = await getPublicSeriesPage(); + + expect(page.items).toHaveLength(SERIES_PAGE_SIZE); + expect(page.items[0]).toEqual({ + _id: '665000000000000000000000', + slug: 'series-0', + title: 'Series 0', + description: '', + date: 100, + thumbnailImage: '', + postCount: 0, + }); + expect(decodeSeriesCursor(page.nextCursor!)).toEqual({ + sortOrder: 0, + date: 100 - (SERIES_PAGE_SIZE - 1), + id: new Types.ObjectId('66500000000000000000000b'), + }); + }); + + it('ends pagination when fewer than one page remains', async () => { + aggregate.mockResolvedValue([]); + await expect(getPublicSeriesPage()).resolves.toEqual({ + items: [], + nextCursor: null, + }); + await expect(getPublicSeriesPage('bad-cursor')).rejects.toThrow( + 'Invalid series cursor' + ); + }); +}); diff --git a/app/lib/getPublicSeriesPage.ts b/app/lib/getPublicSeriesPage.ts new file mode 100644 index 0000000..5b9e425 --- /dev/null +++ b/app/lib/getPublicSeriesPage.ts @@ -0,0 +1,72 @@ +import { Types } from 'mongoose'; +import dbConnect from '@/app/lib/dbConnect'; +import { + decodeSeriesCursor, + encodeSeriesCursor, + seriesCursorFilter, +} from '@/app/lib/seriesCursor'; +import Series from '@/app/models/Series'; +import type { SeriesListItem, SeriesPage } from '@/app/types/Series.d'; + +export const SERIES_PAGE_SIZE = 12; + +interface SeriesListDocument { + _id: Types.ObjectId; + slug: string; + title: string; + description?: string; + date: number; + thumbnailImage?: string; + postCount: number; + sortOrder?: number | null; +} + +export async function getPublicSeriesPage( + rawCursor?: string +): Promise { + const cursor = rawCursor ? decodeSeriesCursor(rawCursor) : null; + if (rawCursor && !cursor) throw new Error('Invalid series cursor'); + + await dbConnect(); + const documents = await Series.aggregate([ + ...(cursor ? [{ $match: seriesCursorFilter(cursor) }] : []), + { $sort: { sortOrder: 1 as const, date: -1 as const, _id: 1 as const } }, + { $limit: SERIES_PAGE_SIZE + 1 }, + { + $project: { + slug: 1, + title: 1, + description: 1, + date: 1, + thumbnailImage: 1, + sortOrder: 1, + postCount: { $size: { $ifNull: ['$posts', []] } }, + }, + }, + ]); + + const hasMore = documents.length > SERIES_PAGE_SIZE; + const pageDocuments = documents.slice(0, SERIES_PAGE_SIZE); + const items: SeriesListItem[] = pageDocuments.map((document) => ({ + _id: document._id.toString(), + slug: document.slug, + title: document.title, + description: document.description || '', + date: document.date, + thumbnailImage: document.thumbnailImage || '', + postCount: document.postCount, + })); + const last = pageDocuments.at(-1); + + return { + items, + nextCursor: + hasMore && last + ? encodeSeriesCursor({ + sortOrder: last.sortOrder ?? null, + date: last.date, + id: last._id, + }) + : null, + }; +} diff --git a/app/lib/seriesCursor.ts b/app/lib/seriesCursor.ts new file mode 100644 index 0000000..becf898 --- /dev/null +++ b/app/lib/seriesCursor.ts @@ -0,0 +1,53 @@ +import { Types } from 'mongoose'; + +export interface SeriesCursor { + sortOrder: number | null; + date: number; + id: Types.ObjectId; +} + +export const encodeSeriesCursor = (cursor: SeriesCursor): string => + Buffer.from( + JSON.stringify([cursor.sortOrder, cursor.date, cursor.id.toString()]) + ).toString('base64url'); + +export const decodeSeriesCursor = (raw: string): SeriesCursor | null => { + if (!raw || raw.length > 256) return null; + + try { + const value: unknown = JSON.parse(Buffer.from(raw, 'base64url').toString()); + if (!Array.isArray(value) || value.length !== 3) return null; + + const [sortOrder, date, id] = value; + if ( + (sortOrder !== null && + (typeof sortOrder !== 'number' || !Number.isFinite(sortOrder))) || + typeof date !== 'number' || + !Number.isFinite(date) || + typeof id !== 'string' || + !/^[a-f\d]{24}$/i.test(id) + ) { + return null; + } + + return { sortOrder, date, id: new Types.ObjectId(id) }; + } catch { + return null; + } +}; + +export const seriesCursorFilter = (cursor: SeriesCursor) => { + const sameOrder = cursor.sortOrder === null ? null : cursor.sortOrder; + const laterOrder = + cursor.sortOrder === null + ? { sortOrder: { $ne: null } } + : { sortOrder: { $gt: cursor.sortOrder } }; + + return { + $or: [ + laterOrder, + { sortOrder: sameOrder, date: { $lt: cursor.date } }, + { sortOrder: sameOrder, date: cursor.date, _id: { $gt: cursor.id } }, + ], + }; +}; diff --git a/app/models/Series.ts b/app/models/Series.ts index 97d0066..4bceca9 100644 --- a/app/models/Series.ts +++ b/app/models/Series.ts @@ -17,6 +17,8 @@ const seriesSchema = new Schema( } ); +seriesSchema.index({ sortOrder: 1, date: -1, _id: 1 }); + const Series = models.Series || model('Series', seriesSchema); export default Series; diff --git a/app/page.tsx b/app/page.tsx index cf1edd0..5bc9eb0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -72,7 +72,7 @@ const Home = async () => { __html: JSON.stringify(siteSchema).replace(/<\//g, '<\\/'), }} /> -
    +
    diff --git a/app/series/page.tsx b/app/series/page.tsx index 4a480ec..62f3d43 100644 --- a/app/series/page.tsx +++ b/app/series/page.tsx @@ -1,15 +1,40 @@ -'use client'; -import React from 'react'; -import SeriesList from '@/app/entities/series/list/SeriesList'; +import { FaBookOpen } from 'react-icons/fa'; +import SeriesListEntry from '@/app/entities/series/list/SeriesListEntry'; +import SeriesLoadMore from '@/app/entities/series/list/SeriesLoadMore'; +import { getPublicSeriesPage } from '@/app/lib/getPublicSeriesPage'; + +export const dynamic = 'force-dynamic'; + +const SeriesListPage = async () => { + const { items, nextCursor } = await getPublicSeriesPage(); -const SeriesListPage = () => { return (

    시리즈

    시리즈별로 글을 확인해보세요. 클릭시 세부 페이지로 이동합니다.

    - + {items.length === 0 ? ( +
    + +

    + No Series Found +

    +
    + ) : ( +
      + {items.map((item, index) => ( + + ))} + {nextCursor && ( + item._id)} + /> + )} +
    + )}
    ); }; diff --git a/app/types/Series.d.ts b/app/types/Series.d.ts index 77e8005..17199ae 100644 --- a/app/types/Series.d.ts +++ b/app/types/Series.d.ts @@ -14,3 +14,19 @@ export interface Series { } export type SeriesDetail = Omit & { posts: Post[] }; + +export type SeriesListItem = Pick< + Series, + | '_id' + | 'slug' + | 'title' + | 'description' + | 'date' + | 'thumbnailImage' + | 'postCount' +>; + +export interface SeriesPage { + items: SeriesListItem[]; + nextCursor: string | null; +}