👉🏻 여기서는 액티브테마와 타임라인의 커서 페이지 네이션 구조를 분석해 봅니다.
Here, we analyze the cursor-based pagination structure of Active Themes and Timelines.
👉🏻 커서페이지네이션은(페이지 네이션 + 무한스크롤)입니다.
Cursor-based pagination is a combination of pagination and infinite scrolling.
👉🏻 보통 우리가 모바일 폰에서 스크롤을 아래로 내려서 화면끝에 도달하면 데이터를 추가로 뒤에 붙이는 방식을 의미합니다.
It typically refers to the method where additional data is appended as you scroll down on a mobile phone and reach the bottom of the screen.
👉🏻 이 부분을 이 SNS페이지에서 구현하는게 커서 페이지 네이션입니다.
The key feature being implemented on this social media page is pagination.
📁 테마호출 및 타임라인 리스트호출 흐름
Flow of Theme and Timeline List Retrieval
✔️ 페이지네이션이 적용되는 부분은 테마디렉토리의 TimelineList.tsx입니다.
The section where pagination is applied is TimelineList.tsx in the theme directory.
— app/usersui/[username]/_components/themes/themeex/TimelineList.tsx
✔️ 추후 나머지 부분도 페이지네이션 적용을 할 예정입니다.
We plan to implement pagination for the remaining sections as well.
✔️ 페이지네이션의 개념을 이해하기 위해서 라우트 실행 후 실제로 데이터가 어떻게 흘러가는지 살펴 볼 필요가 있습니다.
To understand the concept of pagination, it is necessary to examine how data actually flows after a route is executed.
✔️ 아래는 데이터 흐름 경로 설명입니다.
The following is a description of the data flow path.
1)라우트가 실행되면 page.tsx파일을 실행합니다.
When the route is executed, the page.tsx file is executed.
A.이 page.tsx에서 가입되지 않은 사용자 이름으로 접근하면 404(page not found)오류를 발생 시킵니다.
In this page.tsx, accessing the page with a username that does not exist triggers a 404 (page not found) error.
B. @username이 가입된 사용자면 ClientPage.tsx파일을 호출 합니다.
If @username is a registered user, the ClientPage.tsx file is called.
# 라우트 실행 주소(URl) / Route Execution URL
https://aloy-horizon.duckdns.org/@user1 or @your_user_name
# page.tsx 디렉토리 경로 / page.tsx directory path
app/usersui/[username]/page.tsx
💡 여기서 부터는 액티브 테마 설명입니다.
The following is an explanation of the active theme.
2)ClientPage.tsx에서 액티브 테마를 호출합니다.
Call the active theme in ClientPage.tsx.
✔️ app/usersui/[username]/ClientPage.tsx
✔️ 로그인 여부에 따라서 액티브 테마에 초기데이터, 부스트, 좋아요, 공유기능을 액티브 테마에 전달합니다.
Depending on the login status, initial data, boost, like, and share functions are passed to the active theme.
const props = { timeline, username, initialView, onBoost: handleBoost, onLike: handleLike, onShare: handleShare };
// getThemeComponent : import { themeRegistry, getThemeComponent } from './_components/themes'; --> index.ts
const ActiveTheme = getThemeComponent(theme as any) || themeRegistry.themeex?.component || themeRegistry[themeNames[0]]?.component;
3) 액티브 테마 호출 로직
Active theme invocation logic
✔️ 액티브 테마를 불러오는 로직은 다음과 같습니다.
The logic for loading the active theme is as follows.
A.ClientPage.tsx에서 index.ts파일에 저장된 getThemeComponent(theme as any) 함수를 호출합니다.
ClientPage.tsx calls the getThemeComponent(theme as any) function stored in the index.ts file.
- ClientPage.tsx
import { themeRegistry, getThemeComponent } from './_components/themes';
... ...
const ActiveTheme = getThemeComponent(theme as any)
- ./_components/themes/index.ts
# index.ts는 자동으로 생성되는 파일 / index.ts is an automatically generated file.
# 여기서는 themeex를 기준으로 설명함. / The explanation here is based on themeex.
# MainTheme.tsx를 호출해서 내용을 MainTheme변수에 저장하고 themeexCompo변수에 저장
Call MainTheme.tsx, then store the content in the MainTheme variable and the themeexCompo variable.
# themeRegistry에 모두 MainTheme를 사용하면 충돌이 있으므로 themeexComp처럼 다른 이름적용
# Since using MainTheme for everything in the themeRegistry causes conflicts,
# apply a different name like themeexComp
... ...
import { MainTheme as themeexComp} from './themeex/MainTheme';
# component는 호출될 함수로 themeexComp(=Maintheme)를 지정함.
# The `component` specifies `themeexComp` (i.e., `Maintheme`) as the function to be called.
export const themeRegistry = {
mastodon: { component: mastodonComp, label: 'mastodon' },
minimal: { component: minimalComp, label: 'minimal' },
pinafore: { component: pinaforeComp, label: 'pinafore' },
themeex: { component: themeexComp, label: 'themeex' },
} as const;
# typeof : ... pinafore: {...}, themeex: {...}
# keyof : 'mastodon' | 'minimal' | 'pinafore' | 'themeex'
export type ThemeName = keyof typeof themeRegistry;
# 실제로 themeRegistry배열에서 themeex해당 테마의 component인 MainTheme함수를 리턴
# Actually returns the MainTheme function, which is the component of the corresponding theme, from the themeRegistry array themeex
export function getThemeComponent(name: ThemeName) {
return themeRegistry[name]?.component;
}
- MainTheme함수 props / MainTheme function props
# ClientPage.tsx ->
# const props = { timeline, username, initialView, onBoost: handleBoost, onLike: handleLike, onShare: handleShare };
# MainTheme.tsx의 MainTheme함수
# The MainTheme function in MainTheme.tsx
export function MainTheme({ timeline : initialTimeline, username, initialView, onBoost, onLike, onShare }: any) { ... }
💡 액티브 테마 설명 끝 / End of active theme description.
4)MainTheme.tsx의 MainTheme함수에 전달된 props들은 수정후 TimelineList.tsx에 전달됩니다.
The props passed to the MainTheme function in MainTheme.tsx are passed to TimelineList.tsx after modification.
- username은 @username값으로 로그인하지 않은 경우 보여줄데이터나 라우트를 위해서 사용합니다.
Theusernameis used for data or routes to be displayed when the user has not logged in using the@usernamevalue. - onBoost,onLike는 handleBoostClick, handleLikeClick함수에 추가됩니다.
onBoost and onLike are added to the handleBoostClick and handleLikeClick functions. - handleBoostClick,handLikeClick함수는 로그인하지 않은 경우 클릭하면 로그인 페이지로 이동하는 역할을 수행합니다.
ThehandleBoostClickandhandleLikeClickfunctions redirect the user to the login page if they are not logged in.
... ...
import TimelineList from './TimelineList'; // ✅ myapp54
... ...
# MainTheme.tsx의 MainTheme함수
# The MainTheme function in MainTheme.tsx
export function MainTheme({ timeline : initialTimeline, username, initialView, onBoost, onLike, onShare }: any) { ... }
... ...
# 전달 받은 props가 변경된 부분
# Parts where received props have changed
const [timeline, setTimeline] = useState<any[]>(initialTimeline || []);
const handleBoostClick = async (p: any) => {
if (!currentUser) return window.location.href = `/auth/login?next=/@${username}`;
onBoost(p)
};
const handleLikeClick = (p: any) => {
if (!currentUser) return window.location.href = `/auth/login?next=/@${username}`;
onLike(p);
};
... ...
# TimelineList.tsx에 props 전달
# Passing props to TimelineList.tsx
{view === 'timeline' && (
<>
{/* ✅ myapp56 - ADD Dropdown(Home/Local/Federated Tabs ) */}
<TimelineList
timeline={timeline}
tab={tab}
onTabChange={setTab}
currentUser={currentUser}
onDelete={handleDelete}
onBoost={handleBoostClick}
onLike={handleLikeClick}
onShare={onShare}
/>
</>
)} {/* end view === 'timeline' */}
5)TimelineList.tsx
- 각 onBoost,onLike,onShare함수가 내장된 버튼을 클릭하면 ClientPage.tsx의 handleBoost, handleLike, handleShare 함수가 실행됩니다.
Clicking a button with the built-inonBoost,onLike, oronSharefunction triggers the correspondinghandleBoost,handleLike, orhandleSharefunction inClientPage.tsx. - 각 컨텐츠는 .map((p: any, idx: number) => { 으로 화면에 데이터를 표시합니다.
Each piece of content displays data on the screen using.map((p: any, idx: number) => {.
export default function TimelineList({
timeline: initialTimeline, currentUser, tab, onTabChange, onDelete, onBoost, onLike, onShare }: Props) { ... ...
{/**
* p && p.id.map(...)
* - p와 p.id가 있으면 map부분 실행 / If p and p.id exist, execute the map section.
*
* idx: number
* - map()함수에서 자동으로 제공되는 인덱스(0부터 시작)
* The index (starting from 0) automatically provided by the map() function.
*
*/}
{data.filter((p:any) => p && p.id).map((p: any, idx: number) => {
const uniqueKey = `${p.id}-${idx}`;
// ✅ myapp61
const acct = p.account?.acct || '';
const username = p.account?.username || p.username || '';
const acctDomain = acct.includes('@')? acct.split('@')[1] : DOMAIN;
const isLocalAccount = acctDomain === DOMAIN;
const isMine =!!(currentUser && isLocalAccount && username === currentUser && p.account?.id!== `remote_${username}@${acctDomain}`);
... ...
return(
... ...
{/* post*/}
<div className="flex gap-2 items-center flex-wrap">
<b className="text-">{/*✅ myapp61*/ getDisplayName(p)}</b>
<span className="text-[#999] text-xs">@{ /*✅ myapp61*/getHandle(p)
/* new Date(p.created_at).toLocaleString()*/}</span>
{isRemote(p) && <span className="text-[10px]">🌍</span>}
{p.isBoostedPost && <span className="text-[#16a34a] text-xs bg-[#dcfce7] px-1.5 py-0.5 rounded">🔁 {getDisplayName(p.actor)}</span>}
{/* ✅ myapp61 */}
{isMine && (
<button onClick={() => onDelete(p.id)} className="text-[#e0245e] ml-auto text-xs">
🗑 Delete
</button>
)}
</div>
{/* contents*/}
<div className="my-2 leading-[1.4] break-words text- [&_p]:m-0" dangerouslySetInnerHTML={{ __html: p.content }} />
{/* image */}
{p.media_attachments && p.media_attachments.length > 0 && (
<div className={`grid gap-2 mt-2.5 rounded-xl overflow-hidden ${p.media_attachments.length === 1? 'grid-cols-1' : 'grid-cols-2'}`}>
{p.media_attachments.map((m: any) => (
<img key={m.id} src={toAbsolute(m.preview_url || m.url)} alt="media" loading="lazy" className="w-full max-h- object-cover rounded-lg border border-[#e6ecf0]" />
))}
</div>
)}
{/* boost, like, share */}
<div className="flex gap-4 mt-2">
<button onClick={() => onBoost(p)} className={`bg-none border-none cursor-pointer text-[#999] px-2 py-1 rounded-xl text-sm hover:bg-[#f3f4f6] hover:text-[#333] ${p.isMyBoost? '!text-[#1b9c59] font-bold bg-[#dcfce7]' : ''}`}>🔁 {p.boostCount || ''}</button>
<button onClick={() => onLike(p)} className={`bg-none border-none cursor-pointer text-[#999] px-2 py-1 rounded-xl text-sm hover:bg-[#f3f4f6] hover:text-[#333] ${p.isMyLike? '!text-[#e0245e] bg-[#ffe4e6]' : ''}`}>⭐ {p.likeCount || ''}</button>
<button onClick={() => onShare(p)} className="bg-none border-none cursor-pointer text-[#999] px-2 py-1 rounded-xl text-sm hover:bg-[#f3f4f6] hover:text-[#333]">✈</button>
<button className="bg-none border-none cursor-pointer text-[#999] px-2 py-1 rounded-xl text-sm hover:bg-[#f3f4f6] hover:text-[#333]">💬</button>
</div>
... ...
); //end return
})} // end timelielist function
6)타임라인 API 데이터 예시 / Timeline API Data Example
- 아래처럼 타임라인 API를 실행하는 경우 예시 데이터입니다.(로그인한 경우)
The following is example data for executing the Timeline API (when logged in).
https://aloy-horizon.duckdns.org/api/timeline?username=user1&type=home
[
{
"id": "01M4J098H7BH1QZSDQ2B63V4WM",
"content": "\u003Cp\u003Egts public\u003C/p\u003E",
"username": "user1",
"actor": "https://freelifemakers.com/users/user1",
"created_at": "2026-10-10T13:14:05+09:00",
"visibility": "public",
"type": "Note",
"source": "inbox",
"original_id": "https://freelifemakers.com/users/user1/statuses/01M4J098H7BH1QZSDQ2B63V4WM",
"fullId": "https://freelifemakers.com/users/user1/statuses/01M4J098H7BH1QZSDQ2B63V4WM",
"isMyBoost": false,
"boostCount": 0,
"isMyLike": false,
"likeCount": 0,
"isBoostedPost": false,
"isMine": false
},
{
"id": "5cdd75c8-1dfe-4acc-90e9-f24968a8e1f0",
"content": "local public",
"username": "user1",
"actor": "user1",
"created_at": 1791605663435,
"visibility": "public",
"type": "Note",
"source": "mine",
"original_id": "5cdd75c8-1dfe-4acc-90e9-f24968a8e1f0",
"fullId": "https://aloy-horizon.duckdns.org/users/user1/statuses/5cdd75c8-1dfe-4acc-90e9-f24968a8e1f0",
"isMyBoost": false,
"boostCount": 0,
"isMyLike": false,
"likeCount": 0,
"isBoostedPost": false,
"isMine": true
},
{
"id": "608e90d3-f138-47a3-afff-93239335bea8",
"content": "test4 private",
"username": "user1",
"actor": "user1",
"created_at": 1791590136977,
"visibility": "private",
"type": "Note",
"source": "mine",
"original_id": "608e90d3-f138-47a3-afff-93239335bea8",
"fullId": "https://aloy-horizon.duckdns.org/users/user1/statuses/608e90d3-f138-47a3-afff-93239335bea8",
"isMyBoost": false,
"boostCount": 0,
"isMyLike": false,
"likeCount": 0,
"isBoostedPost": false,
"isMine": true
}
]
💡 다음 포스트에서 계속 됩니다. / To be continued in the next post.
마태복음 21장 22절 / Matthew 21: 22
“너희가 기도할 때에 무엇이든지 믿고 구하는 것은 다 받으리라 하시니라”
“And everything you ask in prayer, believing, you will receive.”