๐๐ป ์ด๋ฒ์๋ ์ข์์๋ถ๋ถ์ ๊ตฌ์กฐ ๋ถ๋ถ์ ์ดํด ๋ด
๋๋ค.
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.”