[nextjs]SNS Server-49(myapp59)Β 

πŸ‘‰πŸ» 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.”

Leave a Reply