👉🏻 myapp59는 커서페이지 네이션을 구현합니다.(무한스크롤 + 페이지네이션)
myapp59 implements cursor-based pagination (combining infinite scroll and pagination).
👉🏻 themeex 테마 CSS를 Tailwindcss로 변환했습니다.
I converted the themeex theme CSS to Tailwind CSS.
(FollowersList.tsx FollowingList.tsx NotificationsList.tsx Themetheme.tsx TimelineList.tsx)
👉🏻 페이지네이션은 themeex테마 내의 FollowersList.tsx ,FollowingList.tsx ,NotificationsList.tsx ,TimelineList.tsx 파일에 적용되어 있습니다.
Pagination has been implemented in the FollowersList.tsx, FollowingList.tsx, NotificationsList.tsx, and TimelineList.tsx files within the themeex theme.
👉🏻 전체 코드는 깃허브에서 확인 할 수 있습니다.
You can find the full code on GitHub.
https://github.com/gideonslife01/flm-nextjs
📁 전체 프로젝트 구조 / Overall Project Structure
myapp project/
├── app/ (Next.js App Router)
│ ├── .well-known/webfinger/route.ts -> webfinger
│ ├── hooks/useCursorPagination.ts -> Pagination Hook
│ ├── api/follow/route.ts -> Follow API(temporary)
│ ├── api/announce/route.ts -> Boost(Announcement)
│ ├── api/like/route.ts. -> Like API
│ ├── api/posts/route.ts -> Writing API(deprecated)
│ ├── api/timeline/route.ts -> Timeline API
│ ├── api/followinglist/route.ts -> followinglist API
│ ├── api/followerslist/route.ts -> followerslist API
│ ├── api/v1/instance/route.ts -> auth
│ ├── api/v1/apps/route.ts. -> auth
│ ├── api/v1/accounts/verify_credentials/route.ts -> auth
│ ├── api/v1/statuses/route.ts -> Write Post
│ ├── api/v1/timelines/_home_back/route.ts -> timeline(Depreacted)
│ ├── api/v1/media/route.ts -> Image upload
│ ├── api/v1/media/[id]/route.ts -> Image Delete
│ ├── api/v1/instance/rules/route.ts -> Feature not implemented.
│ ├── api/v1/instance/domain_blocks/route.ts -> Feature not implemented.
│ ├── api/v1/filters/route.ts -> Feature not implemented.
│ ├── api/v1/lists/route.ts -> Feature not implemented.
│ ├── api/v1/announcements/route.ts -> Feature not implemented.
│ ├── api/v1/timelines/_public_back/route.ts -> Feature not implemented.(Deprecated)
│ ├── api/v1/timelines/[type]/route.ts -> Timeline API
│ ├── api/v1/statuses/[id]/context/route.ts -> Post Check,Delete
│ ├── oauth/authorize/route.ts -> auth
│ ├── oauth/token/route.ts -> auth
│ ├── oauth/login-and-authorize/route.ts -> Tursky,Pinafore login processing
│ ├── oauth/revoke/route.ts -> Feature not implemented.
│ ├── api/v1/search/home/route.ts -> search
│ ├── api/v2/search/home/route.ts -> search
│ ├── api/v2/notifications/policy/route.ts-> Feature not implemented.
│ ├── api/v1/accounts/[id]/followers/route.ts -> followers
│ ├── api/v1/accounts/[id]/following/route.ts -> following
│ ├── api/v1/statuses/[id]/route.ts -> -> Post delete(pinafore)
│ ├── api/auth/signup/route.ts -> Website Signup
│ ├── api/auth/login/route.ts -> Website Login
│ ├── api/auth/logout/route.ts -> Website Logout
│ ├── api/auth/refresh/route.ts -> Refresh Token
│ ├── api/cleanup-orphan/route.ts -> post,outbox clean up
│ ├── api/auth/me/route.tsx -> Login Check
│ ├── auth/signup/page.tsx -> Signup UI
│ ├── auth/signup/page.tsx -> Login UI
│ ├── users/[username]/
│ │ ├── statuses/[id]/route.ts -> Indivisual Post
│ │ ├── route.ts -> Acotr Information
│ │ ├── followers/route.ts -> Followers List
│ │ ├── following/route.ts -> Following List
│ │ ├── inbox/route.ts -> Inbox
│ │ └── outbox/route.ts -> outbox
│ ├── usersui/[username]/
│ │ ├── page.tsx -> Timeline UI(Server, 404)
│ │ ├── ClientPage.tsx. -> Timeline UI(Client,Call Active Themes)
│ │ └── _components/themes/
│ │ ├── themeex/ThemeexTheme.tsx -> Example Theme
│ │ ├── themeex/FollowersList.tsx -> Theme Example, FollowersList
│ │ ├── themeex/FollowingList.tsx -> Theme Example, FollowingList
│ │ ├── themeex/NotificationsList.tsx -> Theme Example, NotificationsList
│ │ ├── themeex/TimelineList.tsx -> Theme Example, TimelineList
│ │ ├── pinafore/PinaforeTheme.tsx -> Theme 1
│ │ ├── pinafore/FollowersList.tsx -> Theme 1, FollowersList
│ │ ├── pinafore/FollowingList.tsx -> Theme 1, FollowingList
│ │ ├── pinafore/NotificationsList.tsx -> Theme 1, NotificationsList
│ │ ├── pinafore/TimelineList.tsx -> Theme 1, TimelineList
│ │ ├── mastodon/MastodonTheme.tsx -> Theme 2
│ │ └── minimal/MinimaltTheme.tsx -> Theme 3
│ ├── layout.tsx, page.tsx, globals.css
│ └── favicon.ico
├── lib/
│ ├── theme.tsx -> Theme Provider
│ ├── watchThemes.ts -> Check real-time theme changes
│ ├── auth.ts -> Authentication, User Management
│ ├── ap.ts -> Follow,Undo,Create,Likes,Announce
│ ├── visibility.ts -> Visibility(Public,Unlisted,Private,Direct)
│ └── db.ts -> DB connection
├── data/
│ ├── keys/userIDs/ -> private.pem, public.pem(New)
│ └── keys/ -> private.pem, public.pem(legacy)
├── data.sqlite -> Database(1/3)
├── data.sqlite-wal -> Database(2/3)
├── data.sqlite-shm -> Database(3/3)
├── Caddyfile -> https
├── instrumentation.ts -> Background Server
├── proxy.ts -> middleware.ts - >proxy.ts(for Nextjs16)
├── postcss.config.mjs -> for Nextjs16
└── package.json
📁 프로젝트 시작 / Project Start
📁 라우트 추가 및 코드 수정/ Add route and code modification
✔️ app/hooks/useCursorPagination.ts – 라우트 추가 / Add route
// app/hooks/useCursorPagination.ts
// ✅ myapp59
// - 모든 리스트에서 재사용 (react-query 사용하지 않음) / Reusable across all lists (without using React Query)
'use client';
import { useState, useCallback, useEffect } from 'react';
type CursorResponse<T> = {
data: T[];
nextCursor: string | null;
hasMore: boolean;
};
type UseCursorOptions<T> = {
key: (string | number | undefined)[];
fetcher: (cursor?: string) => Promise<CursorResponse<T>>;
initialData?: T[];
};
export function useCursorPagination<T>({ key, fetcher, initialData = [] }: UseCursorOptions<T>) {
const [pages, setPages] = useState<T[][]>(initialData.length? [initialData] : []);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(true);
const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
const [isLoading, setIsLoading] = useState(pages.length === 0);
// key가 바뀌면 리셋 / Reset if the key changes.
// key = home,federated,local
useEffect(() => {
setPages(initialData.length? [initialData] : []);
setNextCursor(null);
setHasNextPage(true);
setIsLoading(initialData.length === 0);
}, [JSON.stringify(key)]);
const fetchNextPage = useCallback(async () => {
if (isFetchingNextPage ||!hasNextPage) return;
setIsFetchingNextPage(true);
try {
const res = await fetcher(nextCursor || undefined);
setPages(prev => [...prev, res.data]);
setNextCursor(res.nextCursor);
setHasNextPage(res.hasMore);
} catch (e) {
console.error(e);
} finally {
setIsFetchingNextPage(false);
setIsLoading(false);
}
}, [fetcher, nextCursor, hasNextPage, isFetchingNextPage]);
// 첫 로드 / first load
useEffect(() => {
if (pages.length === 0) {
fetchNextPage();
}
}, []);
const flatData = pages.flat();
return {
flatData,
pages: pages.map(data => ({ data })),
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
};
}
✔️ app/api/v1/timelines/[type]/route.ts – 외부앱용 타임라인 / Timeline for external apps
— 이전 프로젝트까지 timelines/public timelines/home으로 사용했으나 [type]에 디렉토리 이름을 마운트 하도록 변경
Previously, the path timelines/public timelines/home was used, but this has been changed to mount the directory name to [type].
— 전체 코드는 깃허브 참조
Refer to GitHub for the full code.
— created_at 값이 어떤 형태로 오든 숫자로 된 타임스탬프(ms)로 셋팅하는 함수
A function that sets the created_at value to a numeric timestamp (in milliseconds), regardless of the input format.
function parseDate(v: any): number {
if (!v) return 0;
if (/^\d{13}$/.test(String(v))) return parseInt(v);
const d = new Date(v).getTime();
return isNaN(d) ? 0 : d;
}
1)if (/^\d{13}$/.test(String(v))) return parseInt(v);
A. 13자리 숫자(타임스탬프)로 저장된 경우 parseInt로 숫자로 변환해서 사용
If stored as a 13-digit number (timestamp), convert it to a number using parseInt and use it.
2) const d = new Date(v).getTime();
A. Date 객체를 이걸 getTime()으로 밀리초로 변환
Convert this Date object into milliseconds using getTime().
— sort((a,b) => ...)는 정렬 기능으로 두 값을 비교해서 순서를 정함
filteredRows.sort((a,b) => parseDate(b.created_at) - parseDate(a.created_at))
1) 리턴값이 음수면 a가 앞으로,양수면 b가 앞으로,0이면 순서 유지
If the return value is negative, a comes first; if positive, b comes first; if zero, the order is maintained.
2)b - a = 내림차순 / Descending order( DESC) = 최신글이 위로 / Latest posts top
3)a - b = 오름차순 / Ascending order (ASC) = 오래된 글이 위로 / Old text on top
4) 배열에 담긴 데이터의 비교방식과 비교횟수는 sort함수 알고리즘에 따라 실행함.
The method and number of comparisons for data stored in an array are determined by the algorithm of the sort function.
— nextCursonr 변수 셋팅 / Setting the nextCursor variable
const hasMore = filteredRows.length > limit;
const sliced = filteredRows.slice(0, limit);
const nextCursor = hasMore ? sliced[sliced.length - 1]?.created_at : null;
1)limit설정 값보다 데이터갯수가 더 많으면 (hasMore) nextCursor는 sliced배열에 담긴 ‘마지막 배열 -1’ 번째 배열의 create_at을 nextCursor로 저장함
If the number of data items exceeds the limit setting (hasMore is true), the nextCursor is set to the created_at value of the element at the second-to-last position of the sliced array.
2)배열은 0번 부터 시작하기 때문에 limit가 20이면 데이터는 21개를 불러옴
Since arrays are zero-indexed, a limit of 20 results in 21 items of data being retrieved.
— hasMore,nextCursor를 NextResponse.json에 포함시켜서 UI에서 데이터를 호출 하도록 실행
Include hasMore and nextCursor in the NextResponse.json so that the UI can fetch the data.
const headers = new Headers();
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Content-Type', 'application/json');
if (nextCursor) {
headers.set('Link', `<https://${DOMAIN}/api/v1/timelines/${type}?max_id=${nextCursor}&limit=${limit}>; rel="next"`);
headers.set('X-Next-Cursor', String(nextCursor));
}
// 외부 앱은 배열만 기대, 내부 themeex는 커서 필요하면 ?format=cursor 추가
// External apps expect only an array; the internal themeex requires `?format=cursor` if a cursor is needed.
if (searchParams.get('format') === 'cursor') {
return NextResponse.json({ data: statuses, nextCursor, hasMore }, { headers });
}
✔️ app/usersui/[username]/_components/themes/themeex/TimelineList.tsx – 로컬 타임라인/Local Timeline
— 타임라인 초기 데이터 불러오기,userCursorPagination함수 불러오기
Load initial timeline data, call the userCursorPagination function.
export default function TimelineList({
timeline: initialTimeline, currentUser, tab, onTabChange, onDelete, onBoost, onLike, onShare }: Props) {
const [showDropdown, setShowDropdown] = useState(false); // ✅ myapp56 - Hom,Local,Federated 탭 추가 / Add Hom, Local, Federated Tabs
// ✅ myapp59 - useCurserPagination
const {
flatData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useCursorPagination({
key: ['timeline', tab],
fetcher: async (cursor) => {
const params = new URLSearchParams();
params.set('limit', '20');
params.set('type', tab);
if (cursor) params.set('max_id', cursor);
const res = await fetch(`/api/v1/timelines/${tab}?${params.toString()}`, { credentials: 'include' });
if (!res.ok) throw new Error('fetch fail');
return res.json();
},
});
... ...
}
— loaderRef로 데이터 끝이 감지되면 fetchNextPage()함수 실행
Execute the fetchNextPage() function when the end of the data is detected via loaderRef.
const loaderRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = loaderRef.current;
if (!el ||!hasNextPage) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting &&!isFetchingNextPage) fetchNextPage();
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
1)new IntersectionObserver로 페이지 스크롤 내려서 어디까지 왔는지 계속 감시
Use new IntersectionObserver to continuously monitor how far down the page has been scrolled.
2){ threshold: 0.3 } 페이지 바닥까지 30% 남아 있으면 fetchNextPage()함수 실행
{ threshold: 0.3 } Execute the fetchNextPage() function when 30% of the page remains to the bottom.
— 데이터 끝으로 데이터 불러오기 감지하는 부분
The section that detects the end of the data to trigger data loading.
... ...
{/* ✅ myapp59 - 무한스크롤 트리거 / Infinite scroll trigger */}
<div ref={loaderRef} className="h-10 flex items-center justify-center text-sm text-[#999]">
{isFetchingNextPage && <span>불러오는 중...</span>}
{!hasNextPage && data.length > 0 && <span className="text-xs">끝 / No more posts</span>}
</div>
✔️ app/api/v1/statuses/[id]/context/route.ts – 버그수정 / Bug fix
— nextjs15버전 이상은 params: Promise사용
For Next.js version 15 or later, use params: Promise.
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params; // ✅ myapp58 - nextjs15+
if (!id) {
return NextResponse.json({ ancestors: [], descendants: [] }, {
headers: { 'Access-Control-Allow-Origin': '*' }
});
}
... ... }
📁 좋아요,부스트 기능 수정 예정
Okay, the boost function is scheduled to be modified.
✔️ 페이지 네이션 기능 추가로 인해 좋아요,부스트 기능 변경 수정 예정
The “Like” and “Boost” functions are scheduled to be modified due to the addition of pagination.
✔️ 좋아요 / favorite(=like)
— /api/v1/statuses/[id]/favourite
✔️ 부스트 / boost(=announce)
— /api/v1/statuses/[id]/reblog
📁 테스트 / Test
✔️ 기존과 동일하고 외관상 바뀐 부분이 없어서 테스트 이미지는 생략합니다.
It remains identical to the previous version with no visible changes, so I am omitting the test images.
마태복음 21장 22절 / Matthew 21: 22
“너희가 기도할 때에 무엇이든지 믿고 구하는 것은 다 받으리라 하시니라”
“And everything you ask in prayer, believing, you will receive.”