Skip to content
Draft
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
19 changes: 19 additions & 0 deletions app/api/series/public/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
2 changes: 1 addition & 1 deletion app/api/series/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
});

return NextResponse.json(series, { status: 201 });
} catch (error: any) {

Check warning on line 33 in app/api/series/route.ts

View workflow job for this annotation

GitHub Actions / Continuous Integration

Unexpected any. Specify a different type
return NextResponse.json(
{ error: error.message || '시리즈 생성에 실패했습니다.' },
{ status: 500 }
Expand All @@ -53,10 +53,10 @@
return NextResponse.json(series, {
status: 200,
headers: {
'Cache-Control': 'public, max-age=30, s-maxage=30',
'Cache-Control': 'no-store',
},
});
} catch (error: any) {

Check warning on line 59 in app/api/series/route.ts

View workflow job for this annotation

GitHub Actions / Continuous Integration

Unexpected any. Specify a different type
return NextResponse.json(
{ error: error.message || '시리즈 목록을 불러오는데 실패했습니다.' },
{ status: 500 }
Expand Down
10 changes: 5 additions & 5 deletions app/entities/profile/AboutMe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { githubLink, linkedinLink } from '@/app/lib/constants/landingPageData';
const AboutMe = () => {
return (
<section className="relative overflow-visible rounded-2xl from-primary-caribbean to-gray-100 dark:from-primary-bangladesh dark:to-primary-dark p-6 md:p-10">
<div className="flex gap-8 md:gap-12 items-center">
<div className="relative mx-auto md:mx-0 group/duck">
<div className="flex flex-col md:flex-row gap-8 md:gap-12 items-center">
<div className="relative mx-auto md:mx-0 shrink-0 group/duck">
<div className="absolute -inset-1 bg-gradient-to-r from-gray-300 to-gray-400 dark:from-gray-600 dark:to-gray-700 rounded-full blur opacity-20 animate-pulse"></div>
<div className="relative h-44 w-44 overflow-hidden rounded-full ring-4 ring-white dark:ring-gray-400 shadow-xl">
<Image
Expand All @@ -26,14 +26,14 @@ const AboutMe = () => {
className="absolute inset-0 object-cover w-full h-full bg-gray-500 transition-opacity duration-700 group-hover/duck:opacity-0"
/>
</div>
<div className="absolute z-50 -top-16 left-1/2 -translate-x-1/2 opacity-0 group-hover/duck:opacity-100 transition-all duration-300 pointer-events-none scale-95 group-hover/duck:scale-100">
<div className=" bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 text-xs px-3 py-2 rounded-lg shadow-lg whitespace-nowrap border border-gray-200 dark:border-gray-700">
<div className="absolute z-50 -top-16 left-1/2 -translate-x-1/2 w-max max-w-[calc(100vw-2rem)] opacity-0 group-hover/duck:opacity-100 transition-all duration-300 pointer-events-none scale-95 group-hover/duck:scale-100">
<div className="bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 text-xs px-3 py-2 rounded-lg shadow-lg text-center border border-gray-200 dark:border-gray-700">
<p>저는 커피☕와 사진 📸을 좋아하는 개발자입니다~</p>
</div>
<div className="absolute left-1/2 -translate-x-1/2 -bottom-1.5 w-3 h-3 bg-white dark:bg-gray-800 border-r border-b border-gray-200 dark:border-gray-700 rotate-45"></div>
</div>
</div>
<div className="grid gap-6">
<div className="grid w-full min-w-0 gap-6">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100">
About Me
Expand Down
8 changes: 4 additions & 4 deletions app/entities/profile/Experience.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,17 @@ const Experience = () => {
{experiences.map((exp) => (
<div
key={exp.company}
className="flex items-center gap-4 p-4 rounded-xl bg-gray-50 dark:bg-neutral-800/50"
className="grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-4 gap-y-1 p-4 rounded-xl bg-gray-50 dark:bg-neutral-800/50 md:flex md:items-center md:gap-4"
>
<div className="p-2.5 rounded-lg bg-primary-deep/10 dark:bg-primary-deep/20">
<div className="row-span-2 p-2.5 rounded-lg bg-primary-deep/10 dark:bg-primary-deep/20">
{exp.type === 'work' ? (
<HiOutlineBriefcase className="w-5 h-5 text-primary dark:text-emerald-400" />
) : (
<HiOutlineAcademicCap className="w-5 h-5 text-primary dark:text-emerald-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-bold text-gray-900 dark:text-gray-100">
{exp.company}
</h3>
Expand All @@ -59,7 +59,7 @@ const Experience = () => {
{exp.role}
</p>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
<span className="col-start-2 text-sm text-gray-500 dark:text-gray-400 md:whitespace-nowrap">
{exp.period}
</span>
</div>
Expand Down
63 changes: 0 additions & 63 deletions app/entities/series/list/SeriesList.tsx

This file was deleted.

21 changes: 21 additions & 0 deletions app/entities/series/list/SeriesListEntry.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li
className="opacity-0 translate-y-5 animate-popUp"
style={{
animationDelay: `${Math.min(index, 8) * 0.06}s`,
animationFillMode: 'forwards',
}}
>
<SeriesPreview item={item} />
</li>
);
}
105 changes: 105 additions & 0 deletions app/entities/series/list/SeriesLoadMore.tsx
Original file line number Diff line number Diff line change
@@ -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<SeriesListItem[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const sentinelRef = useRef<HTMLLIElement>(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) => (
<SeriesListEntry
key={item._id}
item={item}
index={initialCount + index}
/>
))}
{cursor && (
<li
ref={sentinelRef}
className="col-span-full flex justify-center py-6"
aria-live="polite"
>
{error ? (
<button
type="button"
onClick={() => void loadMore()}
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
시리즈를 불러오지 못했습니다. 다시 시도
</button>
) : (
<span className="text-sm text-weak">
{loading ? '시리즈를 불러오는 중...' : '더 많은 시리즈 불러오기'}
</span>
)}
</li>
)}
</>
);
}
12 changes: 7 additions & 5 deletions app/entities/series/list/SeriesPreview.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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"
/>
) : (
Expand All @@ -47,11 +47,13 @@ const SeriesPreview = ({ item }: SeriesPreviewProps) => {
<div className="flex items-center gap-2 text-sm text-weak mb-3">
<span className="flex items-center gap-1">
<FaCalendar className="w-4 h-4" />
{new Date(item.date).toLocaleDateString()}
{new Date(item.date).toLocaleDateString('ko-KR', {
timeZone: 'Asia/Seoul',
})}
</span>
<span className="ml-auto flex items-center gap-1">
<FaBookOpen className="w-4 h-4" />
{item.posts.length || 0} posts
{item.postCount} posts
</span>
</div>

Expand Down
59 changes: 59 additions & 0 deletions app/entities/series/list/__test__/SeriesLoadMore.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 } }) => <li>{item.title}</li>,
}));

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(
<ul>
<SeriesLoadMore
initialCursor="first"
initialCount={12}
initialIds={['initial']}
/>
</ul>
);

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();
});
Loading
Loading