ππ» 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”