👉🏻 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.

✔️ 내가 부스트 한글이 pinafore에 도착하는지 확인
Check if my Boost Hangul arrives at Pinafore.

✔️ pinafore에서 부스트하기 / Boosting in Pinafore

✔️ 카운터 증가 확인 / Verify counter increment

요한복음 8장 32절 / John 8:32
“그리고 너희는 진리를 알게 될 것이며, 진리가 너희를 자유롭게 할 것이다.”
“Then you will know the truth ,and the truth will set you free”