[nextjs]SNS Server-18(myapp28)

πŸ‘‰πŸ» myapp28μ—λŠ” ν…Œλ§ˆνŒŒμΌμ— λΆ€μŠ€νŠΈλ²„νŠΌμ„ μΆ”κ°€ν•©λ‹ˆλ‹€.
In myapp28, a boost button is added to the theme file.

πŸ‘‰πŸ» λ‚΄κ°€ μ“΄ κΈ€μ—λŠ” λΆ€μŠ€νŠΈ ν•  수 μ—†μŠ΅λ‹ˆλ‹€.
You cannot boost your own posts.

πŸ‘‰πŸ» λ‚΄κ°€ λΆ€μŠ€νŠΈ λ²„νŠΌμ„ λˆ„λ₯΄κ³  λ‹€μ‹œ ν•œλ²ˆ 더 λˆ„λ₯΄λ©΄ λΆ€μŠ€νŠΈ μ·¨μ†Œκ°€ λ©λ‹ˆλ‹€.
If I press the boost button and then press it again, the boost is cancelled.

πŸ‘‰πŸ» λ‚΄κ°€ λΆ€μŠ€νŠΈ ν•œ 글을 λ‹€λ₯Έ μ‚¬λžŒμ΄ λΆ€μŠ€νŠΈν•˜λ©΄ μΉ΄μš΄ν„°κ°€ μ¦κ°€ν•©λ‹ˆλ‹€.
When someone else boosts a post I have boosted, the counter increases.

πŸ‘‰πŸ» 전체 μ½”λ“œλŠ” κΉƒν—ˆλΈŒμ—μ„œ 확인 ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
You can find the full code on GitHub.

https://github.com/gideonslife01/flm-nextjs

πŸ“ 전체 ν”„λ‘œμ νŠΈ ꡬ쑰 / Overall Project Structure

myapp28/  
β”œβ”€β”€ app/  (Next.js App Router)
β”‚   β”œβ”€β”€ .well-known/webfinger/route.ts  -> webfinger
β”‚   β”œβ”€β”€ 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
β”‚   β”œβ”€β”€ api/timeline/route.ts  -> Timeline API
β”‚   β”œβ”€β”€ 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
β”‚   β”‚   └── _components/themes/
β”‚   β”‚       β”œβ”€β”€ themeex/ThemeexTheme.tsx   -> Example Theme
β”‚   β”‚       β”œβ”€β”€ pinafore/PinaforeTheme.tsx -> Theme 1
β”‚   β”‚       β”œβ”€β”€ 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
β”‚   β”œβ”€β”€ ap.ts                  -> Follow Accept
β”‚   └── db.ts                  -> DB connection
β”œβ”€β”€ data/
β”‚   └── keys/                  -> private.pem, public.pem
β”œβ”€β”€ data.sqlite                -> Database
β”œβ”€β”€ Caddyfile                  -> https 
β”œβ”€β”€ instrumentation.ts         -> Background Server
└── package.json

πŸ“ ν”„λ‘œμ νŠΈ μ‹œμž‘(myapp28)
Project Start (myapp28)

npx create-next-app@latest

πŸ“ SQLiteμ„€μΉ˜ / Installing SQLite

cd ~/myapp28
npm install better-sqlite3
npm install -D @types/better-sqlite3

πŸ“ chokidar μ„€μΉ˜ / Installing chokidar

— μ‹€μ‹œκ°„ 파일 및 폴더변경 κ°μ‹œκΈ°λŠ₯
Real-time file and folder change monitoring function

npm install -D chokidar

πŸ“ DDNS,httpsμ„€μ • / DDNS,https settings

πŸ“ λ„λ©”μΈν—ˆμš©, @userid μ‚¬μš©ν•˜κΈ°
Allowing domains, using @userid

— next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* allow domain*/
  allowedDevOrigins: ['aloy-horizon.duckdns.org', '*.duckdns.org'],

  /* host.domain.org/@userid */
  async rewrites() {
    return [
      {
        source: '/@:username',
        destination: '/usersui/:username',
      },
    ]
  }
};


export default nextConfig;

πŸ“ 이전 ν”„λ‘œμ νŠΈμ—μ„œ μ‹ κ·œ ν”„λ‘œμ νŠΈλ‘œ 볡사할 파일
Files to copy from the previous project to the new project

myapp project/  
β”œβ”€β”€ app/  (Next.js App Router)
β”‚   β”œβ”€β”€ .well-known/webfinger/route.ts  -> webfinger
β”‚   β”œβ”€β”€ api/                  -> API
β”‚   β”œβ”€β”€ users/                -> Inbox,Outbox,Post,Actor
β”‚   β”œβ”€β”€ usersui/              -> Timeline UI,Theme
β”‚   β”œβ”€β”€ layout.tsx            -> Privoder
β”œβ”€β”€ lib/
β”œβ”€β”€ data/                     -> Key
β”œβ”€β”€ data.sqlite               -> Database
β”œβ”€β”€ Caddyfile                 -> https 
β”œβ”€β”€ next.config.ts            -> DDNS,@
└── instrumentation.ts        -> Background Server

πŸ“ μ½”λ“œ μˆ˜μ • / Code Modification


πŸ’‘ app/api/announce/route.ts

— μžκΈ°μžμ‹ μ˜ 글은 λΆ€μŠ€νŠΈν•˜μ§€ λͺ»ν•΄κ²Œ 기본적으둜 λ§‰ν˜€ μžˆμŠ΅λ‹ˆλ‹€.
By default, you are prevented from boosting your own posts.

— target은 λ°μ΄ν„°λ² μ΄μŠ€κ°€ κ°€λ¦¬ν‚€λŠ” μ£Όμ†Œμ΄κ³  DOMAIN은 λ‚΄μ„œλ²„μ˜ μ£Όμ†Œμž…λ‹ˆλ‹€.
‘target’ is the address pointed to by the database, while ‘DOMAIN’ is the address of my server.

— λ‘κ°œμ˜ 값이 μΌμΉ˜ν•˜λ©΄ 였λ₯˜λ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€.
An error is raised if the two values ​​match.

    if (target.startsWith(`https://${DOMAIN}/users/${username}`)) {
      return NextResponse.json({ ok: false, error: 'cannot boost own post' }, { status: 400 });
    }

βœ”οΈ app/usersui/[username]/page.tsx

— κΈ°μ‘΄ μ½”λ“œλŠ” inbox_posts에 λŒ€ν•œ 뢀뢄을 κ³ λ €ν•˜μ§€ μ•Šμ•„μ„œ “cannot boost own post”였λ₯˜λ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€.
The existing code does not account for inbox_posts, causing a “cannot boost own post” error.

  const handleBoost = async (post: any) => {
    const targetId = post.original_id || post.id;
    const fullTarget = targetId.startsWith('http')
     ? targetId
      : `https://${process.env.NEXT_PUBLIC_DOMAIN || 'aloy-horizon.duckdns.org'}/users/${post.username}/statuses/${targetId}`;

    await fetch(`/api/announce`, {
      method: post.isMyBoost? 'DELETE' : 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, target: fullTarget })
    });
    await refreshTimeline();
  };

— myapp28μ—μ„œ λ°°λ‹¬λœκΈ€(inbox_posts)μ—λŒ€ν•œ 뢀뢄을 μˆ˜μ •ν–ˆμŠ΅λ‹ˆλ‹€.
I modified the section regarding posts delivered to myapp28 (inbox_posts).

 ... ... 
 useEffect(() => {
    params.then(p => {
      setUsername(p.username);
      fetch(`/api/timeline?username=${p.username}`)
       .then(r => r.json())
       .then(data => {
          console.log('πŸ“¦ timeline', data[0]); // βœ… myapp28
          setTimeline(data);
          setLoading(false);
        });
    });
  }, [params]);
... ...
 // βœ… myapp28
 const handleBoost = async (post: any) => {
  let target = post.original_id || post.id;
  console.log('πŸ” 원본 post 데이터/original data', post); 

  if (!target.startsWith('http')) {
    // inbox 글이면 actorκ°€ μ§„μ§œ 주인!
    // actor = https://freelifemakers.com/users/user1 같은 ν˜•νƒœμΌ 수 있음
    // If it's an inbox message, the actor is the actual owner!
    // The actor might be in a format like https://freelifemakers.com/users/user1

    if (post.actor && post.actor.startsWith('http')) {
      // actor URLμ—μ„œ username μΆ”μΆœ / Extract username from actor URL
      try {
        const actorUrl = new URL(post.actor);
        const parts = actorUrl.pathname.split('/');
        const usersIdx = parts.indexOf('users');

        if (usersIdx !== -1) {
        // inbox_posts
          const actorUsername = parts[usersIdx + 1];
          target = `${actorUrl.origin}/users/${actorUsername}/statuses/${target}`;
        } else {
        // posts
          target = `https://${process.env.NEXT_PUBLIC_DOMAIN || 'aloy-horizon.duckdns.org'}/users/${post.username}/statuses/${target}`;
        }
      } catch {
        target = `https://${process.env.NEXT_PUBLIC_DOMAIN || 'aloy-horizon.duckdns.org'}/users/${post.username}/statuses/${target}`;
      }
    } else {
      target = `https://${process.env.NEXT_PUBLIC_DOMAIN || 'aloy-horizon.duckdns.org'}/users/${post.username}/statuses/${target}`;
    }
  }
  
  console.log('πŸ” boost click', { target, isMyBoost: post.isMyBoost });

  // Optimistic UI
  setTimeline(prev => prev.map(p =>
    p.id === post.id
      ? {...p, isMyBoost:!p.isMyBoost, boostCount: (p.boostCount || 0) + (p.isMyBoost? -1 : 1) }
      : p
  ));

  try {   
    const res = await fetch(`/api/announce`, {
      method: post.isMyBoost? 'DELETE' : 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, target })
    });

    const data = await res.json();
    console.log('βœ… announce res', data);
    if (!res.ok) throw new Error(data.error);
    await refreshTimeline();
  } catch (e) {
    console.error('❌ boost μ‹€νŒ¨', e);
    await refreshTimeline();
  }
};

βœ”οΈ app/usersui/[username]/inbox

— λΆ€μŠ€νŠΈ μ·¨μ†Œν•  λ•Œ 원본 κΈ€ μ‚­μ œ κΈˆμ§€μ²˜λ¦¬
Prevent deletion of the original post when cancelling a boost.

      else if (objType === 'Announce') {
         const announceId = typeof obj === 'object' ? obj.id : null;
         const objectId = typeof obj.object === 'string' ? obj.object : obj.object?.id;
         if (announceId) {
           db.prepare('DELETE FROM announces WHERE id = ?').run(announceId);
           
           // βœ… myapp28 - Announce Undoμ‹œ 원본 κΈ€ μ‚­μ œ κΈˆμ§€ 처리 / Prevent deletion of the original post when performing "Announce Undo."
           //db.prepare('DELETE FROM inbox_posts WHERE original_id = ? AND actor = ?').run(objectId, body.actor);
           console.log(`πŸ—‘ [${username}] Announce μ·¨μ†Œ: ${announceId}`);
         }
      }

βœ”οΈ app/api/timeline/route.ts

— isMyBoost , boostCount μΆ”κ°€ν•΄μ„œ λΆ€μŠ€νŠΈ ν–ˆλŠ”μ§€ μ—¬λΆ€λ₯Ό μ²΄ν¬ν•˜κ³  λΆ€μŠ€νŠΈ 된 횟수λ₯Ό μΆ”κ°€ν–ˆμŠ΅λ‹ˆλ‹€.
Added isMyBoost and boostCount to track whether a boost has been applied and to record the number of boosts.

  // βœ… myapp28
  const enriched = timeline.map((p: any) => {
    // ν’€ URL λ§Œλ“€κΈ° / Create full URL
    let fullId = p.original_id || p.id;
    if (!fullId.startsWith('http')) {
      fullId = `https://${DOMAIN}/users/${p.username}/statuses/${fullId}`;
    }
    
    // inbox 글은 original_idκ°€ 이미 ν’€ URL일 수 있음
    // The original_id of an inbox post might already be a full URL.
    let fullOriginalId = p.original_id || p.id;
    if (p.original_id && !p.original_id.startsWith('http')) {
      fullOriginalId = `https://${DOMAIN}/users/${p.username}/statuses/${p.original_id}`;
    }

    const isMyBoost = boostedSet.has(p.original_id) || 
                      boostedSet.has(p.id) || 
                      boostedSet.has(fullId) ||
                      boostedSet.has(fullOriginalId) ||
                      (p.original_id && boostedSet.has(p.original_id));

    const boostCount = (db.prepare(`SELECT COUNT(*) as c FROM announces WHERE object = ? OR object = ? OR object = ?`)
      .get(fullId, p.original_id || '', p.id || '') as any)?.c || 0;

    return {
      ...p,
      fullId, // λ””λ²„κΉ…μš© / For debugging purposes
      isMyBoost,
      boostCount
    };
  });

  return NextResponse.json(enriched); // βœ… myapp28 - 버그 μˆ˜μ • / Bug fixes

βœ”οΈapp/usersui/[username]/_components/themes/pinafore/PinaforeTheme.tsx

— import 였λ₯˜ μˆ˜μ • / Fix import error

import type { ThemeName } from '../themeNames'; // βœ… myapp28
import { themeNames } from '../themeNames';

— λΆ€μŠ€νŠΈ μΉ΄μš΄ν„° / Boost Counter

<button onClick={() => onBoost(p)} className={p.isMyBoost? 'boosted' : ''}>πŸ” {p.boostCount || ''}</button>

πŸ“ ν…ŒμŠ€νŠΈ / Test

βœ”οΈ pinaforeμ—μ„œ μ“΄ κΈ€ λ‚΄ μ„œλ²„λ‘œ λ°°λ‹¬λ˜λŠ”μ§€ 확인 및 λΆ€μŠ€νŠΈ ν•˜κΈ°, μΉ΄μš΄ν„° 확인
Check if posts from Pinafore are being delivered to my server, boost them, and check the counter.

Boost1

βœ”οΈ λ‚΄κ°€ λΆ€μŠ€νŠΈ ν•œκΈ€μ΄ pinafore에 λ„μ°©ν•˜λŠ”μ§€ 확인
Check if my Boost Hangul arrives at Pinafore.

Boost Message

βœ”οΈ pinaforeμ—μ„œ λΆ€μŠ€νŠΈν•˜κΈ° / Boosting in Pinafore

Pinafore Boost

βœ”οΈ μΉ΄μš΄ν„° 증가 확인 / Verify counter increment

Boost Counter

μš”ν•œλ³΅μŒ 8μž₯ 32절 / John 8:32

“그리고 λ„ˆν¬λŠ” 진리λ₯Ό μ•Œκ²Œ 될 것이며, 진리가 λ„ˆν¬λ₯Ό 자유둭게 ν•  것이닀.”

“Then you will know the truth ,and the truth will set you free”

Leave a Reply