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

๐Ÿ‘‰๐Ÿป ์ด๋ฒˆ์—๋Š” ์ข‹์•„์š”๋ถ€๋ถ„์˜ ๊ตฌ์กฐ ๋ถ€๋ถ„์„ ์‚ดํŽด ๋ด…๋‹ˆ๋‹ค.
Next, we will examine the structure of the “like” section.

๐Ÿ‘‰๐Ÿป ์ข‹์•„์š” ๋ถ€๋ถ„์˜ ๋Œ€๋žต์ ์ธ ๋ฐ์ดํ„ฐ ํ๋ฆ„์€ ์ด์ „์˜ ํŽ˜์ด์ง€๋„ค์ด์…˜์ด๋‚˜ ์•กํ‹ฐ๋ธŒ ํ…Œ๋งˆ ๋ถ€๋ถ„๊ณผ ๊ฑฐ์˜ ๊ฐ™์Šต๋‹ˆ๋‹ค.
The general data flow for the “likes” section is nearly identical to that of the pagination or active theme sections mentioned earlier.

๐Ÿ“ “์ข‹์•„์š””๋ถ€๋ถ„์˜ ๋ฐ์ดํ„ฐ ํ๋ฆ„
Data flow for the “Like” section

1) ClientPage.tsx

A.ClientPage.tsx์—์„œ handleLikeํ•จ์ˆ˜๋ฅผ ํ…Œ๋งˆ๋””๋ ‰ํ† ๋ฆฌ์˜ MainTheme.tsx์˜ MainThemeํ•จ์ˆ˜์— ํŒŒ๋ผ๋ฉ”ํ„ฐ๋กœ ์ „๋‹ฌ ๋ฉ๋‹ˆ๋‹ค.
In ClientPage.tsx, the handleLike function is passed as a parameter to the MainTheme function, which is located in MainTheme.tsx within the theme directory.

B.์•กํ‹ฐ๋ธŒํ…Œ๋งˆ์˜ ์ž‘๋™์— ๊ด€๋ จ๋œ๋ถ€๋ถ„์€ [nextjs]SNS Server-51(myapp61+)[Active theme]  ํฌ์ŠคํŠธ๋ฅผ ์ฐธ์กฐํ•˜์„ธ์š”
For details regarding the operation of the active theme, please refer to the post: [nextjs]SNS Server-51(myapp61+)[Active theme].

 ... ... 
  const timelineRef = useRef<any[]>([]);
  useEffect(() => { timelineRef.current = timeline; }, [timeline]);
... ...
 const handleLike = async (post: any) => {

    // โœ… ํ•ญ์ƒ ์ตœ์‹  timeline์—์„œ ์ฐพ๊ธฐ / Always find from the latest timeline
    const latest = timelineRef.current.find((p: any) => p.id === post.id) || post;
    let target = latest.original_id || latest.id;

    if (!target.startsWith('http')) {
      if (latest.actor && latest.actor.startsWith('http')) {
        try {
          const actorUrl = new URL(latest.actor);
          const parts = actorUrl.pathname.split('/');
          const usersIdx = parts.indexOf('users');
          const actorUsername = usersIdx!== -1? parts[usersIdx + 1] : latest.username;
          target = `${actorUrl.origin}/users/${actorUsername}/statuses/${target}`;
        } catch {
          target = `https://${process.env.NEXT_PUBLIC_DOMAIN || 'aloy-horizon.duckdns.org'}/users/${latest.username}/statuses/${target}`;
        }
      } else {
        target = `https://${process.env.NEXT_PUBLIC_DOMAIN || 'aloy-horizon.duckdns.org'}/users/${latest.username}/statuses/${target}`;
      }
    }

    const currentlyLiked = latest.isMyLike;
    console.log('โญ like click', { target, isMyLike: currentlyLiked, shortId: target.split('/').pop() });

    // Optimistic + ์„œ๋ฒ„๊ฐ’์œผ๋กœ ๋ฎ์–ด์“ฐ๊ธฐ
    // Overwrite with Optimistic + Server values
    setTimeline(prev => prev.map((p: any) =>
      p.id === latest.id
       ? {...p, isMyLike:!currentlyLiked, likeCount: (p.likeCount || 0) + (currentlyLiked? -1 : 1) }
        : p
    ));

    try {
      const res = await fetch(`/api/like`, {
        method: currentlyLiked? 'DELETE' : 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, target }),
        cache: 'no-store'
      });
      const data = await res.json();
      console.log('โญ like ์‘๋‹ต / Like response', data);

      // ์„œ๋ฒ„๊ฐ€ ์ค€ ๊ฐ’ ํ™œ์šฉํ•˜๊ธฐ
      // Using the value provided by the server
      setTimeline(prev => prev.map((p: any) =>
        p.id === latest.id
         ? {...p, isMyLike: data.isMyLike, likeCount: data.likeCount }
          : p
      ));

      // ํ˜น์‹œ ๋ชจ๋ฅด๋‹ˆ ํ•œ๋ฒˆ ๋” ๋™๊ธฐํ™”
      // Synchronize one more time just in case
      if (data.alreadyLiked || data.alreadyUnliked) {
        await refreshTimeline();
      }

    } catch (e) {
      console.error('โŒ like ์‹คํŒจ', e);
      await refreshTimeline();
    }
  };

... ...
 const props = { timeline, username, initialView, onBoost: handleBoost, onLike: handleLike, onShare: handleShare };
... ...
  return <ActiveTheme {...props} />;

2)MainTheme.tsx

A.ํ…Œ๋งˆ๋””๋ ‰ํ† ๋ฆฌ์˜ MainThem.tsxํŒŒ์ผ์˜ MainThemeํ•จ์ˆ˜์— ํŒŒ๋ผ๋ฉ”ํ„ฐ๋กœ ์ข‹์•„์š”(onLike: handleLike)๊ฐ€ ์ „๋‹ฌ ๋ฉ๋‹ˆ๋‹ค.
The onLike prop (with the value handleLike) is passed as a parameter to the MainTheme function within the MainTheme.tsx file located in the theme directory.

B.onLikeํ•จ์ˆ˜๋Š” ๋‹ค์‹œ handleLikeClickํ•จ์ˆ˜ ๋‚ด์—์„œ ํ˜ธ์ถœ ๋ฉ๋‹ˆ๋‹ค.
The onLike function is called within the handleLikeClick function.

C. ์ด๋•Œ ์ด handleLikeClickํ•จ์ˆ˜๋Š” ๋กœ๊ทธ์ธํ•˜์ง€ ์•Š์€๊ฒฝ์šฐ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
In this case, the handleLikeClick function performs the action of redirecting the user to the login page if they are not logged in.

D.handleLikeClickํ•จ์ˆ˜๋ฅผ TimelineList ์ปดํฌ๋„ŒํŠธ์˜ ํŒŒ๋ผ๋ฉ”ํ„ฐ(onLIke)๋กœ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.
Pass the handleLikeClick function as a parameter (onLike) to the TimelineList component.

... ...
export function MainTheme({ timeline : initialTimeline, username, initialView, onBoost, onLike, onShare }: any) {
... ...
  const handleLikeClick = (p: any) => {
    if (!currentUser) return window.location.href = `/auth/login?next=/@${username}`;
    onLike(p);
  };
... ...

  return (
    <> ... ...
               {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' */}
 ... ...
    </>
  )
}

3)TimelineList.tsx

A.ํŒŒ๋ผ๋ฉ”ํ„ฐ๋กœ ๋ฐ›์€ onLike๋Š” return()๊ตฌ๋ฌธ ์•ˆ์˜ ๋ฒ„ํŠผ ์ด๋ฒคํŠธ์ธ onClick์— ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.
The onLike parameter is applied to the onClick event of the button within the return() statement.

B.์ข‹์•„์š” ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ๊ฒฐ๊ตญ ClientPage.tsx์˜ handleClickํ•จ์ˆ˜ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.
Clicking the “like” button ultimately triggers the handleClick function in ClientPage.tsx.

... ...
export default function TimelineList({ 
  timeline: initialTimeline, currentUser, tab, onTabChange, onDelete, onBoost, onLike, onShare }: Props) { 
... ...
  const flat = (flatData || []).flat().filter(Boolean) as any[];
  const data = flat.length > 0 ? flat : initialTimeline.filter((p:any) => p && p.id);
... ...
  return(
      <>
... ...
{data.filter((p:any) => p && p.id).map((p: any, idx: number) => {
... ...
              {/** 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>
... ...
      })}
... ...
     </>
  )
... ...
}

๐Ÿ“ API

โœ”๏ธ API์˜ ๊ฒฝ๋กœ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.
The API path is as follows:

A.app/api/like/route.ts

โœ”๏ธ ์ข‹์•„์š” ์ €์žฅ / Like Save

A. ๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ๊ธ€์ธ ๊ฒฝ์šฐ
In the case of content I have written

... ...
export async function POST(req: NextRequest) {
... ...
  const { target } = await req.json();
  const username = authUser;
... ...

  const existing = db.prepare(`SELECT id FROM likes WHERE actor =? AND object LIKE '%' ||? || '%'`).get(myActor, shortId) as any;
  if (existing) {
    const likeCount = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||? || '%'`).get(shortId) as any).c;
    return NextResponse.json({ ok: true, alreadyLiked: true, isMyLike: true, likeCount });
  }
... ...
  db.prepare('INSERT OR IGNORE INTO likes (id, actor, object, username) VALUES (?,?,?,?)')
   .run(likeDoc.id, myActor, likeDoc.object.replace('/posts/', '/statuses/'), username);

  const likeCount = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||? || '%'`).get(shortId) as any).c;
  return NextResponse.json({ ok: true, isMyLike: true, likeCount });
}
... ...

a) ์ข‹์•„์š”๋ฅผ ์‹คํ–‰ํ–ˆ๋Š”์ง€ ํ™•์ธ(existing)ํ•˜๊ณ  ์กด์žฌํ•˜๋ฉด ๋ฆฌํ„ด
Check if a ‘like’ has already been cast (existing), and return if it already exists.

b)existing์ด ์—†์œผ๋ฉด db์— ์ข‹์•„์š”๋ฅผ ์ €์žฅํ•˜๊ณ  { ok: true, isMyLike: true, likeCount } ๋ฆฌํ„ด
If existing does not exist, save the “like” to the DB and return { ok: true, isMyLike: true, likeCount }.

B. ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ๊ธ€์ธ๊ฒฝ์šฐ
In the case of a post from another server

... ...
export async function POST(req: NextRequest) {
... ...
  const { target } = await req.json();
  const username = authUser;
... ...
  if (!isOwnPost) {
    try {
      const pr = await signedFetch(target, username);
      if (pr.ok) {
        const d = await pr.json();
        apObjectId = (d.id || target).replace('/posts/', '/statuses/');
        const info = await getActorData((d.attributedTo || d.actor) as any, username);
        inbox = info.inbox;
      }
    } catch {}
  }
... ...
const likeDoc = isOwnPost
   ? { id: `${myActor}/likes/${crypto.randomUUID()}`, actor: myActor, object: apObjectId }
    : (await sendLike(inbox!, apObjectId, username)).likeDoc;

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

  const likeCount = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||? || '%'`).get(shortId) as any).c;

  return NextResponse.json({ ok: true, isMyLike: true, likeCount });
}

a)๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ๊ธ€์ด ์•„๋‹ˆ๋ฉด(!isOwnPost) ์›๋ณธ ๊ธ€์˜ ์„œ๋ฒ„์—์„œ ๊ธ€์„ ์ฐพ๊ธฐ
If I did not write the post (!isOwnPost), I will find the post on the original post’s server.

b)target์€ ์ƒ๋Œ€๋ฐฉ์„œ๋ฒ„์— ์›๋ณธ๊ธ€์ด ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋Š” ์šฉ๋„๋กœ ์‚ฌ์šฉํ•จ
Target is used to check if the original post exists on the counterpart server.

c)signedFetchํ•จ์ˆ˜์™€ getActorDataํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด์„œ ์‹ค์ œ๋กœ ์›๋ณธ์˜ ๊ธ€์˜ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ด
The signedFetch and getActorData functions are used to actually retrieve information about the original post.

d)๋‚ด ์„œ๋ฒ„์˜ ๊ธ€์ด ์•„๋‹ˆ๋ฉด sendLikeํ•จ์ˆ˜๋กœ ์›๋ณธ๊ธ€์ด ์œ„์น˜ํ•œ ์„œ๋ฒ„์— ์ข‹์•„์š”๋ฅผ ์ €์žฅ
If the post is not on my server, use the sendLike function to save the like to the server where the original post is located.

e)๋‚ด ์„œ๋ฒ„์— ์ข‹์•„์š”๋ฅผ ์ €์žฅํ•˜๊ณ  { ok: true, isMyLike: true, likeCount }๋ฅผ ๋ฆฌํ„ด
Save the ‘like’ to my server and return { ok: true, isMyLike: true, likeCount }

โœ”๏ธ ์ข‹์•„์š” ์‚ญ์ œ / Delete Like

A. app/api/like/route.ts

 const myActor = `https://${DOMAIN}/users/${username}`;
  const shortId = target.replace('/posts/', '/statuses/').split('/').pop()!.split('?')[0];

  const likeRow = db.prepare(`SELECT id, object FROM likes WHERE actor =? AND object LIKE '%' ||? || '%'`).get(myActor, shortId) as any;

  if (!likeRow) {
    const likeCount = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||? || '%'`).get(shortId) as any).c;
    return NextResponse.json({ ok: true, alreadyUnliked: true, isMyLike: false, likeCount });
  }

a)๋‚ด๊ฐ€ ๋‚ด ์„œ๋ฒ„์˜ ๊ธ€์— ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ์ ์ด ์žˆ๋Š”์ง€ ํ™•์ธ ํ•˜๊ธฐ(likeRow)
Check whether I have already ‘liked’ a post on my server (likeRow)

b)์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ์ ์ด ์—†์œผ๋ฉด{ ok: true, alreadyUnliked: true, isMyLike: false, likeCount } ์ด ๊ฐ’์„ ๋ฆฌํ„ด
If you haven’t liked it before, return this value { ok: true, alreadyUnliked: true, isMyLike: false, likeCount }.

  if (!isOwnPost) {
    try {
      const u = new URL(objectForCheck);
      const parts = u.pathname.split('/').filter(Boolean);
      const idx = parts.indexOf('users');
      const inbox = idx!== -1? `${u.origin}/users/${parts[idx+1]}/inbox` : null;
      if (inbox) {
        await sendUndoLike(inbox, likeRow.id, objectForCheck, username);
      }
    } catch (e) {
      console.error('Undo Like ๋ฐฐ๋‹ฌ ์‹คํŒจ', e);
    }
  }

a)๋‚ด ์„œ๋ฒ„์˜ ๊ธ€์ด ์•„๋‹ˆ๋ฉด ์›๋ณธ ๊ธ€์˜ ์ฃผ์†Œ์— ์ข‹์•„์š” ์ทจ์†Œ ๋ฐฐ๋‹ฌ
If the post is not from my server, forward the “cancel like” action to the original post’s URL.


  db.prepare('DELETE FROM likes WHERE id =?').run(likeRow.id);
  const likeCount = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||? || '%'`).get(shortId) as any).c;
  return NextResponse.json({ ok: true, isMyLike: false, likeCount });

a) ๋‚ด์„œ๋ฒ„์—์„œ๋„ ์ข‹์•„์š”๋ฅผ ์‚ญ์ œํ•˜๊ณ  { ok: true, isMyLike: false, likeCount } ์ด ๊ฐ’์„ ๋ฆฌํ„ด
Delete the “like” on my server as well, and return the following value: { ok: true, isMyLike: false, likeCount }.

๋งˆํƒœ๋ณต์Œ 21์žฅ 22์ ˆ / Matthew 21: 22

“๋„ˆํฌ๊ฐ€ ๊ธฐ๋„ํ•  ๋•Œ์— ๋ฌด์—‡์ด๋“ ์ง€ ๋ฏฟ๊ณ  ๊ตฌํ•˜๋Š” ๊ฒƒ์€ ๋‹ค ๋ฐ›์œผ๋ฆฌ๋ผ ํ•˜์‹œ๋‹ˆ๋ผ”

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

Leave a Reply