[nextjs]SNS Server-50(myapp60) 

👉🏻 myapp60에서는 페이지네이션 기능 추가로 인해 발생한 좋아요,부스트 부분 오류를 수정합니다.
In myapp60, we are fixing errors related to the “Like” and “Boost” features that arose from the addition of pagination.

👉🏻 페이지네이션 부분을 완료 합니다.
I will complete the pagination section.

👉🏻 엑티브테마 자동전환이 되지 않는 문제를 수정합니다.
Fixes an issue where the active theme does not switch automatically.

👉🏻 전체 코드는 깃허브에서 확인 할 수 있습니다.
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/v1/statuses/[id]/favourite/route.ts -> favourite(for pinafore)
│   ├── api/v1/statuses/[id]/unfavourite/route.ts -> unfavourite(for pinafore)
│   ├── api/v1/statuses/[id]/reblog/route.ts  -> reblog[boost](for pinafore)
│   ├── api/v1/statuses/[id]/unreblog/route.ts  -> unreblog[unboost](for 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


📁 좋아요 + 부스트 수정 / Modify Like + Boost

✔️ 라우트 추가 / Add route


app/api/v1/statuses/[id]/favourite/route.ts

app/api/v1/statuses/[id]/unfavourite/route.ts 

app/api/v1/statuses/[id]/reblog/route.ts 

app/api/v1/statuses/[id]/unreblog/route.ts

✔️ app/api/v1/statuses/[id]/favourite/route.ts

// ✅ myapp60 - favorite for pinafore

export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
import { getActorData, sendLike, signedFetch } from '@/lib/ap';
const DOMAIN = process.env.DOMAIN || 'aloy-horizon.duckdns.org';

function getUser(req: Request) {
  const auth = req.headers.get('Authorization')?.replace('Bearer ','').trim();
  if (auth) {
    const o = db.prepare('SELECT username FROM oauth_tokens WHERE access_token=?').get(auth) as any;
    if (o?.username) return o.username;
  }
  const cookie = req.headers.get('cookie') || '';
  const m = cookie.match(/refresh_token=([^;]+)/);
  if (m) {
    const s = db.prepare('SELECT username FROM sessions WHERE refresh_token=?').get(m[1]) as any;
    if (s?.username) return s.username;
  }
  return null;
}

export async function POST(req: NextRequest, { params }: { params: Promise<{id: string}> }) {
  const authUser = getUser(req);
  if (!authUser) return NextResponse.json({ error: 'Unauthorize' }, { status: 401 });
  const username = authUser;
  const { id } = await params;

  let target = id;
  let post: any = db.prepare(`SELECT * FROM posts WHERE id=?`).get(id) as any;
  if (post) {
    target = `https://${DOMAIN}/users/${post.username}/statuses/${post.id}`;
  } else {
    post = db.prepare(`SELECT * FROM inbox_posts WHERE id=? OR original_id=? OR original_id LIKE '%' ||?|| '%'`).get(id, id, id) as any;
    if (post?.original_id) target = post.original_id;
    else if (post) target = post.original_id || id;
    else target = id.startsWith('http')? id : `https://${DOMAIN}/users/${username}/statuses/${id}`;
  }

  const myActor = `https://${DOMAIN}/users/${username}`;
  let apObjectId = target.replace('/posts/', '/statuses/');
  let inbox: string | null = null;
  const isOwn = apObjectId.includes(`${DOMAIN}/users/${username}/`);

  if (!isOwn) {
    try {
      const pr = await signedFetch(apObjectId, username);
      if (pr.ok) {
        const d = await pr.json();
        apObjectId = (d.id || apObjectId).replace('/posts/', '/statuses/');
        const info = await getActorData((d.attributedTo || d.actor) as any, username);
        inbox = info.inbox;
      }
    } catch {}
  }

  const shortId = apObjectId.split('/').pop()!.split('?')[0].split('#')[0];
  const existing = db.prepare(`SELECT id FROM likes WHERE actor=? AND object LIKE '%' ||?|| '%'`).get(myActor, shortId) as any;
  if (existing) {
    const c = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||?|| '%'`).get(shortId) as any).c;
    return NextResponse.json({ id: id, favourited: true, favourites_count: c, reblogged: false });
  }

  let likeDoc: any;
  try {
    if (isOwn ||!inbox) {
      likeDoc = { id: `${myActor}/likes/${crypto.randomUUID()}`, actor: myActor, object: apObjectId };
    } else {
      likeDoc = (await sendLike(inbox!, apObjectId, username)).likeDoc;
    }
  } catch (e: any) {
    console.log(`⚠ sendLike 실패/sendLike failed, 로컬에만 저장/Save locally only: ${e.message}`);
    likeDoc = { id: `${myActor}/likes/${crypto.randomUUID()}`, actor: myActor, object: apObjectId };
  }

  db.prepare('INSERT OR IGNORE INTO likes (id, actor, object, username) VALUES (?,?,?,?)')
  .run(likeDoc.id, myActor, likeDoc.object.replace('/posts/', '/statuses/'), username);

  const c = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||?|| '%'`).get(shortId) as any).c;
 console.log(`💜 v1-favourite`);
  return NextResponse.json({
    id: id,
    favourited: true,
    favourites_count: c,
    reblogged: false,
    account: { id: username },
  });
}

✔️ app/api/v1/statuses/[id]/unfavourite/route.ts

// ✅ myapp60 - for pinafore - unfavourite

export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
import { getActorData, sendUndoLike, signedFetch } from '@/lib/ap';
const DOMAIN = process.env.DOMAIN || 'aloy-horizon.duckdns.org';

function getUser(req: Request) {
  const auth = req.headers.get('Authorization')?.replace('Bearer ','').trim();
  if (auth) {
    const o = db.prepare('SELECT username FROM oauth_tokens WHERE access_token=?').get(auth) as any;
    if (o?.username) return o.username;
  }
  const cookie = req.headers.get('cookie') || '';
  const m = cookie.match(/refresh_token=([^;]+)/);
  if (m) {
    const s = db.prepare('SELECT username FROM sessions WHERE refresh_token=?').get(m[1]) as any;
    if (s?.username) return s.username;
  }
  return null;
}

export async function POST(req: NextRequest, { params }: { params: Promise<{id: string}> }) {
  const authUser = getUser(req);
  if (!authUser) return NextResponse.json({ error: 'Unauthorize' }, { status: 401 });
  const username = authUser;
  const { id } = await params;

  let target = id;
  let post: any = db.prepare(`SELECT * FROM posts WHERE id=?`).get(id) as any;
  if (post) {
    target = `https://${DOMAIN}/users/${post.username}/statuses/${post.id}`;
  } else {
    post = db.prepare(`SELECT * FROM inbox_posts WHERE id=? OR original_id=? OR original_id LIKE '%' ||?|| '%'`).get(id, id, id) as any;
    if (post?.original_id) target = post.original_id;
    else target = id.startsWith('http')? id : `https://${DOMAIN}/users/${username}/statuses/${id}`;
  }

  const myActor = `https://${DOMAIN}/users/${username}`;
  let apObjectId = target.replace('/posts/', '/statuses/');
  let inbox: string | null = null;
  const isOwn = apObjectId.includes(`${DOMAIN}/users/${username}/`);

  if (!isOwn) {
    try {
      const pr = await signedFetch(apObjectId, username);
      if (pr.ok) {
        const d = await pr.json();
        apObjectId = (d.id || apObjectId).replace('/posts/', '/statuses/');
        const info = await getActorData((d.attributedTo || d.actor) as any, username);
        inbox = info.inbox;
      }
    } catch {}
  }

  const shortId = apObjectId.split('/').pop()!.split('?')[0].split('#')[0];
  const likeRow = db.prepare(`SELECT id, object FROM likes WHERE actor=? AND object LIKE '%' ||?|| '%'`).get(myActor, shortId) as any;

  if (likeRow &&!isOwn && inbox) {
    try {
      await sendUndoLike(inbox, likeRow.id, likeRow.object, username);
      console.log(`💔 [${username}] Undo Like 전송 -> ${inbox}`);
    } catch (e: any) {
      console.log(`⚠ Undo Like 실패, 로컬만 삭제: ${e.message}`);
    }
  }

  if (likeRow) {
    db.prepare(`DELETE FROM likes WHERE id=?`).run(likeRow.id);
  } else {
    // id로 직접 매칭 안되면 shortId LIKE로 한번 더 삭제
    // If direct matching by ID fails, perform an additional deletion using `shortId LIKE`.
    db.prepare(`DELETE FROM likes WHERE actor=? AND object LIKE '%' ||?|| '%'`).run(myActor, shortId);
  }

  const c = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||?|| '%'`).get(shortId) as any).c;
 console.log(`💔 v1-unfavourite`);
  return NextResponse.json({
    id: id,
    favourited: false,
    favourites_count: c,
    reblogged: false,
  });
}

✔️ app/api/v1/statuses/[id]/reblog/route.ts – 깃허브 참조 / Refer to GitHub.

✔️ app/api/v1/statuses/[id]/unreblog/route.ts – 깃허브 참조 / Refer to GitHub.


📁 테마 변경 수정 / Theme Change Modification

✔️ 테마가 변경되어도 Pinafore 테마로 고정되는 문제를 수정했습니다.
Fixed an issue where the theme remained locked to “Pinafore” even after the theme was changed.

✔️ 새로운 테마를 만들어서 업로드하면 테마파일이 존재하는지 확인하고 index.ts파일을 생성합니다.
When you create and upload a new theme, the system checks for the existence of the theme file and generates an index.ts file.

✔️ 테마디렉토리 명은 자유롭게 수정가능하고 테마디렉토리 내의 파일이름은 모두 동일하게 적용해야합니다.
You are free to rename the theme directory, but the filenames within the directory must remain exactly the same.

✔️ 테마를 삭제하는 경우는 관리자 모드에서 삭제하도록 처리할 예정입니다.
We plan to handle theme deletion via the administrator mode.

— lib/watchThemas.ts

for (const folderName of folders) {
... ...
      // write code to index.ts themeName.ts files
      const indexContent = `'use client';\n ${imports}
export const themeRegistry = {
${registry}} as const;
export type ThemeName = keyof typeof themeRegistry;
export function getThemeComponent(name: ThemeName) {
  return themeRegistry[name]?.component;
}
`;
      const namesContent = `export const themeNames = ${JSON.stringify(validFolders)} as const;
export type ThemeName = typeof themeNames[number];
`;
}
... ...  

// ✅ myapp60
  if (process.env.NODE_ENV!== 'production') {
    let t: NodeJS.Timeout;
    chokidar.watch(themesDir, {
      depth: 1,
      ignoreInitial: true,
      ignored: (p: string) => {
        const b = path.basename(p);
        return b === 'index.ts' || b === 'themeNames.ts';
      }
    }).on('all', (event) => {
      // Check addDir, unlinkDir, add, unlink
      clearTimeout(t);
      t = setTimeout(generate, 1000); // ✅ 1초 대기 / Wait 1 second
    });
  }

📁 페이지 네이션 테스트 / Pagination Test

 ✔️ 임시라우트 생성 / Create temporary route

— 한번에 많은 글을 쓰고 동시삭제하는 라우트 입니다.
This is a route for writing multiple posts at once and deleting them simultaneously.

1)api/test/bulk/route.ts

# 1. 글 50개 생성 (로그인 상태에서) / Generate 50 posts (while logged in)
curl -X POST https://aloy-horizon.duckdns.org/api/test/bulk \
  -H "Content-Type: application/json" \
  -b "refresh_token=YOUR_TOKEN" \
  -d '{"count":50}'

# 2. 삭제 / delete
curl -X DELETE https://aloy-horizon.duckdns.org/api/test/bulk \
  -H "Content-Type: application/json" \
  -b "refresh_token=YOUR_TOKEN" \

— 리모트 서버글 강제 삭제 라우트
Route for forced deletion of remote server posts

1) api/test/nuke-remote/route.ts

2) 기본적으로 로컬서버와 리모트 서버의 글이 일치하지 않으면 삭제 할 수 없습니다.
By default, you cannot delete the content if the versions on the local server and the remote server do not match.

3) 아래는 로컬서버 글은 없는데 리모트 서버만 글이 남아 있는경우 삭제 할 수 있는 라우트 입니다.
The following is a route that allows you to delete posts that exist only on the remote server but not on the local server.

4) 임시로 사용하고 프로덕션에서는 삭제 해야합니다.
It should be used temporarily and removed in the production environment.

# 1. 상태 확인 / Check Status
curl -b "refresh_token=YOUR_TOKEN" https://aloy-horizon.duckdns.org/api/test/nuke-remote

# 2. 글 삭제 / post delete
curl -X DELETE -b "refresh_token=YOUR_TOKEN" \
  "https://aloy-horizon.duckdns.org/api/test/nuke-remote?id=01913d88-51bd-4564-9f0a-820ec7e9fb0e"

✔️ 코드 수정 / Code Modification

— app/usersui/[username]/ClientPage.tsx

  // ✅ myapp60 
  // - 페이지네이션은 TimelineList가 담당, 여기선 빈 배열로 시작
  // - Pagination is handled by TimelineList; start with an empty array here.
  useEffect(() => {
    if (!username) return;
    setTimeline([]);
    setLoading(false);
  }, [username]);

  const refreshTimeline = async () => {
    if (!username) return;
    const json = await fetch(`/api/v1/timelines/home?limit=20&format=cursor&t=${Date.now()}`, { cache: 'no-store', credentials: 'include' }).then(r => r.json());
    const updated = Array.isArray(json)? json : json.data;
    setTimeline(updated || []);
  };

— app/usersui/[username]/_components/themes/themeex/MainTheme.tsx

# 모든 테마파일이름을 MainTheme.tsx로 변경
# Rename all theme files to MainTheme.tsx.

— app/usersui/[username]/ClientPage.tsx

  const {
    flatData,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useCursorPagination({
    key: ['timeline', tab],
    initialData: initialTimeline.slice(0, 20), // ✅ myapp60
    fetcher: async (cursor) => {
      const params = new URLSearchParams();
      params.set('limit', '20');
      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');
      const json = await res.json();
      const arr = Array.isArray(json)? json : (json.data || json.statuses || []);
      const withMine = arr.map((p:any) => ({
      ...p,
        isMine: p.username === currentUser || p.account?.username === currentUser || p.acct === currentUser
      }));
      return {
        data: withMine,
        nextCursor: withMine.length? withMine[withMine.length-1].id : null,
        hasMore: withMine.length >= 20
      };
    },
  });

  // ✅ myapp60 - flatData만 사용, initialTimeline 중복 사용 안 함
  const flat = (flatData || []).flat().filter(Boolean) as any[];
  const data = flat.length > 0? flat : initialTimeline.filter((p:any) => p && p.id);
     
... ... 
     {data.filter((p:any) => p && p.id).map((p: any, idx: number) => {
        const uniqueKey = `${p.id}-${idx}`;
        const isMine = !!(currentUser && (
          p.isMine || p.username === currentUser || p.acct === currentUser || p.account?.username === currentUser
        ));
        return (
... ...
                {currentUser && isMine && (
                  <button onClick={() => onDelete(p.id)} className="text-[#e0245e] ml-auto text-xs hover:underline">
                    🗑 Delete
                  </button>
                )}
... ...
        );
      })}

📁 테스트 / Test

✔️ 페이지네이션 +테마 / Pagination + Theme

pagenation + theme

✔️좋아요 + 부스트(로컬) / Like + Boost (Local)

Local

✔️좋아요 + 부스트 / Like + Boost(GTS)

GTS

마태복음 21장 22절 / Matthew 21: 22

“너희가 기도할 때에 무엇이든지 믿고 구하는 것은 다 받으리라 하시니라”

“And everything you ask in prayer, believing, you will receive.”

Leave a Reply