[nextjs]SNS Server-51(myapp61+)[Active theme]ย 

๐Ÿ‘‰๐Ÿป ์—ฌ๊ธฐ์„œ๋Š” ์•กํ‹ฐ๋ธŒํ…Œ๋งˆ์™€ ํƒ€์ž„๋ผ์ธ์˜ ์ปค์„œ ํŽ˜์ด์ง€ ๋„ค์ด์…˜ ๊ตฌ์กฐ๋ฅผ ๋ถ„์„ํ•ด ๋ด…๋‹ˆ๋‹ค.
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๊ฐ’์œผ๋กœ ๋กœ๊ทธ์ธํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๋ณด์—ฌ์ค„๋ฐ์ดํ„ฐ๋‚˜ ๋ผ์šฐํŠธ๋ฅผ ์œ„ํ•ด์„œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
    The username is used for data or routes to be displayed when the user has not logged in using the @username value.
  • onBoost,onLike๋Š” handleBoostClick, handleLikeClickํ•จ์ˆ˜์— ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค.
    onBoost and onLike are added to the handleBoostClick and handleLikeClick functions.
  • handleBoostClick,handLikeClickํ•จ์ˆ˜๋Š” ๋กœ๊ทธ์ธํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ํด๋ฆญํ•˜๋ฉด ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋Š” ์—ญํ• ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
    The handleBoostClick and handleLikeClick functions 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-in onBoost, onLike, or onShare function triggers the corresponding handleBoost, handleLike, or handleShare function in ClientPage.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.”

Leave a Reply