ππ» 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.”