ππ» myapp60μμλ νμ΄μ§λ€μ΄μ
κΈ°λ₯ μΆκ°λ‘ μΈν΄ λ°μν μ’μμ,λΆμ€νΈ λΆλΆ μ€λ₯λ₯Ό μμ ν©λλ€.
In myapp60, we are fixing errors related to the “Like” and “Boost” features that arose from the addition of pagination.
ππ» νμ΄μ§λ€μ΄μ
λΆλΆμ μλ£ ν©λλ€.
I will complete the pagination section.
ππ» μν°λΈν
λ§ μλμ νμ΄ λμ§ μλ λ¬Έμ λ₯Ό μμ ν©λλ€.
Fixes an issue where the active theme does not switch automatically.
ππ» μ 체 μ½λλ κΉνλΈμμ νμΈ ν μ μμ΅λλ€.
You can find the full code on GitHub.
https://github.com/gideonslife01/flm-nextjs
π μ 체 νλ‘μ νΈ κ΅¬μ‘° / Overall Project Structure
myapp project/
βββ app/ (Next.js App Router)
β βββ .well-known/webfinger/route.ts -> webfinger
β βββ hooks/useCursorPagination.ts -> Pagination Hook
β βββ 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(deprecated)
β βββ api/timeline/route.ts -> Timeline API
β βββ api/followinglist/route.ts -> followinglist API
β βββ api/followerslist/route.ts -> followerslist API
β βββ api/v1/instance/route.ts -> auth
β βββ api/v1/apps/route.ts. -> auth
β βββ api/v1/accounts/verify_credentials/route.ts -> auth
β βββ api/v1/statuses/route.ts -> Write Post
β βββ api/v1/timelines/_home_back/route.ts -> timeline(Depreacted)
β βββ api/v1/media/route.ts -> Image upload
β βββ api/v1/media/[id]/route.ts -> Image Delete
β βββ api/v1/instance/rules/route.ts -> Feature not implemented.
β βββ api/v1/instance/domain_blocks/route.ts -> Feature not implemented.
β βββ api/v1/filters/route.ts -> Feature not implemented.
β βββ api/v1/lists/route.ts -> Feature not implemented.
β βββ api/v1/announcements/route.ts -> Feature not implemented.
β βββ api/v1/timelines/_public_back/route.ts -> Feature not implemented.(Deprecated)
β βββ api/v1/timelines/[type]/route.ts -> Timeline API
β βββ api/v1/statuses/[id]/context/route.ts -> Post Check,Delete
β βββ oauth/authorize/route.ts -> auth
β βββ oauth/token/route.ts -> auth
β βββ oauth/login-and-authorize/route.ts -> Tursky,Pinafore login processing
β βββ oauth/revoke/route.ts -> Feature not implemented.
β βββ api/v1/search/home/route.ts -> search
β βββ api/v2/search/home/route.ts -> search
β βββ api/v2/notifications/policy/route.ts-> Feature not implemented.
β βββ api/v1/accounts/[id]/followers/route.ts -> followers
β βββ api/v1/accounts/[id]/following/route.ts -> following
β βββ api/v1/statuses/[id]/route.ts -> Post delete(pinafore)
β βββ api/v1/statuses/[id]/favourite/route.ts -> favourite(for pinafore)
β βββ api/v1/statuses/[id]/unfavourite/route.ts -> unfavourite(for pinafore)
β βββ api/v1/statuses/[id]/reblog/route.ts -> reblog[boost](for pinafore)
β βββ api/v1/statuses/[id]/unreblog/route.ts -> unreblog[unboost](for pinafore)
β βββ api/auth/signup/route.ts -> Website Signup
β βββ api/auth/login/route.ts -> Website Login
β βββ api/auth/logout/route.ts -> Website Logout
β βββ api/auth/refresh/route.ts -> Refresh Token
β βββ api/cleanup-orphan/route.ts -> post,outbox clean up
β βββ api/auth/me/route.tsx -> Login Check
β βββ auth/signup/page.tsx -> Signup UI
β βββ auth/signup/page.tsx -> Login UI
β βββ 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(Server, 404)
β β βββ ClientPage.tsx. -> Timeline UI(Client,Call Active Themes)
β β βββ _components/themes/
β β βββ themeex/ThemeexTheme.tsx -> Example Theme
β β βββ themeex/FollowersList.tsx -> Theme Example, FollowersList
β β βββ themeex/FollowingList.tsx -> Theme Example, FollowingList
β β βββ themeex/NotificationsList.tsx -> Theme Example, NotificationsList
β β βββ themeex/TimelineList.tsx -> Theme Example, TimelineList
β β βββ pinafore/PinaforeTheme.tsx -> Theme 1
β β βββ pinafore/FollowersList.tsx -> Theme 1, FollowersList
β β βββ pinafore/FollowingList.tsx -> Theme 1, FollowingList
β β βββ pinafore/NotificationsList.tsx -> Theme 1, NotificationsList
β β βββ pinafore/TimelineList.tsx -> Theme 1, TimelineList
β β βββ 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
β βββ auth.ts -> Authentication, User Management
β βββ ap.ts -> Follow,Undo,Create,Likes,Announce
β βββ visibility.ts -> Visibility(Public,Unlisted,Private,Direct)
β βββ db.ts -> DB connection
βββ data/
β βββ keys/userIDs/ -> private.pem, public.pem(New)
β βββ keys/ -> private.pem, public.pem(legacy)
βββ data.sqlite -> Database(1/3)
βββ data.sqlite-wal -> Database(2/3)
βββ data.sqlite-shm -> Database(3/3)
βββ Caddyfile -> https
βββ instrumentation.ts -> Background Server
βββ proxy.ts -> middleware.ts - >proxy.ts(for Nextjs16)
βββ postcss.config.mjs -> for Nextjs16
βββ package.json
π νλ‘μ νΈ μμ / Project Start
π μ’μμ + λΆμ€νΈ μμ / Modify Like + Boost
βοΈ λΌμ°νΈ μΆκ° / Add route
app/api/v1/statuses/[id]/favourite/route.ts
app/api/v1/statuses/[id]/unfavourite/route.ts
app/api/v1/statuses/[id]/reblog/route.ts
app/api/v1/statuses/[id]/unreblog/route.ts
βοΈ app/api/v1/statuses/[id]/favourite/route.ts
// β
myapp60 - favorite for pinafore
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
import { getActorData, sendLike, signedFetch } from '@/lib/ap';
const DOMAIN = process.env.DOMAIN || 'aloy-horizon.duckdns.org';
function getUser(req: Request) {
const auth = req.headers.get('Authorization')?.replace('Bearer ','').trim();
if (auth) {
const o = db.prepare('SELECT username FROM oauth_tokens WHERE access_token=?').get(auth) as any;
if (o?.username) return o.username;
}
const cookie = req.headers.get('cookie') || '';
const m = cookie.match(/refresh_token=([^;]+)/);
if (m) {
const s = db.prepare('SELECT username FROM sessions WHERE refresh_token=?').get(m[1]) as any;
if (s?.username) return s.username;
}
return null;
}
export async function POST(req: NextRequest, { params }: { params: Promise<{id: string}> }) {
const authUser = getUser(req);
if (!authUser) return NextResponse.json({ error: 'Unauthorize' }, { status: 401 });
const username = authUser;
const { id } = await params;
let target = id;
let post: any = db.prepare(`SELECT * FROM posts WHERE id=?`).get(id) as any;
if (post) {
target = `https://${DOMAIN}/users/${post.username}/statuses/${post.id}`;
} else {
post = db.prepare(`SELECT * FROM inbox_posts WHERE id=? OR original_id=? OR original_id LIKE '%' ||?|| '%'`).get(id, id, id) as any;
if (post?.original_id) target = post.original_id;
else if (post) target = post.original_id || id;
else target = id.startsWith('http')? id : `https://${DOMAIN}/users/${username}/statuses/${id}`;
}
const myActor = `https://${DOMAIN}/users/${username}`;
let apObjectId = target.replace('/posts/', '/statuses/');
let inbox: string | null = null;
const isOwn = apObjectId.includes(`${DOMAIN}/users/${username}/`);
if (!isOwn) {
try {
const pr = await signedFetch(apObjectId, username);
if (pr.ok) {
const d = await pr.json();
apObjectId = (d.id || apObjectId).replace('/posts/', '/statuses/');
const info = await getActorData((d.attributedTo || d.actor) as any, username);
inbox = info.inbox;
}
} catch {}
}
const shortId = apObjectId.split('/').pop()!.split('?')[0].split('#')[0];
const existing = db.prepare(`SELECT id FROM likes WHERE actor=? AND object LIKE '%' ||?|| '%'`).get(myActor, shortId) as any;
if (existing) {
const c = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||?|| '%'`).get(shortId) as any).c;
return NextResponse.json({ id: id, favourited: true, favourites_count: c, reblogged: false });
}
let likeDoc: any;
try {
if (isOwn ||!inbox) {
likeDoc = { id: `${myActor}/likes/${crypto.randomUUID()}`, actor: myActor, object: apObjectId };
} else {
likeDoc = (await sendLike(inbox!, apObjectId, username)).likeDoc;
}
} catch (e: any) {
console.log(`β sendLike μ€ν¨/sendLike failed, λ‘컬μλ§ μ μ₯/Save locally only: ${e.message}`);
likeDoc = { id: `${myActor}/likes/${crypto.randomUUID()}`, actor: myActor, object: apObjectId };
}
db.prepare('INSERT OR IGNORE INTO likes (id, actor, object, username) VALUES (?,?,?,?)')
.run(likeDoc.id, myActor, likeDoc.object.replace('/posts/', '/statuses/'), username);
const c = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||?|| '%'`).get(shortId) as any).c;
console.log(`π v1-favourite`);
return NextResponse.json({
id: id,
favourited: true,
favourites_count: c,
reblogged: false,
account: { id: username },
});
}
βοΈ app/api/v1/statuses/[id]/unfavourite/route.ts
// β
myapp60 - for pinafore - unfavourite
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
import { getActorData, sendUndoLike, signedFetch } from '@/lib/ap';
const DOMAIN = process.env.DOMAIN || 'aloy-horizon.duckdns.org';
function getUser(req: Request) {
const auth = req.headers.get('Authorization')?.replace('Bearer ','').trim();
if (auth) {
const o = db.prepare('SELECT username FROM oauth_tokens WHERE access_token=?').get(auth) as any;
if (o?.username) return o.username;
}
const cookie = req.headers.get('cookie') || '';
const m = cookie.match(/refresh_token=([^;]+)/);
if (m) {
const s = db.prepare('SELECT username FROM sessions WHERE refresh_token=?').get(m[1]) as any;
if (s?.username) return s.username;
}
return null;
}
export async function POST(req: NextRequest, { params }: { params: Promise<{id: string}> }) {
const authUser = getUser(req);
if (!authUser) return NextResponse.json({ error: 'Unauthorize' }, { status: 401 });
const username = authUser;
const { id } = await params;
let target = id;
let post: any = db.prepare(`SELECT * FROM posts WHERE id=?`).get(id) as any;
if (post) {
target = `https://${DOMAIN}/users/${post.username}/statuses/${post.id}`;
} else {
post = db.prepare(`SELECT * FROM inbox_posts WHERE id=? OR original_id=? OR original_id LIKE '%' ||?|| '%'`).get(id, id, id) as any;
if (post?.original_id) target = post.original_id;
else target = id.startsWith('http')? id : `https://${DOMAIN}/users/${username}/statuses/${id}`;
}
const myActor = `https://${DOMAIN}/users/${username}`;
let apObjectId = target.replace('/posts/', '/statuses/');
let inbox: string | null = null;
const isOwn = apObjectId.includes(`${DOMAIN}/users/${username}/`);
if (!isOwn) {
try {
const pr = await signedFetch(apObjectId, username);
if (pr.ok) {
const d = await pr.json();
apObjectId = (d.id || apObjectId).replace('/posts/', '/statuses/');
const info = await getActorData((d.attributedTo || d.actor) as any, username);
inbox = info.inbox;
}
} catch {}
}
const shortId = apObjectId.split('/').pop()!.split('?')[0].split('#')[0];
const likeRow = db.prepare(`SELECT id, object FROM likes WHERE actor=? AND object LIKE '%' ||?|| '%'`).get(myActor, shortId) as any;
if (likeRow &&!isOwn && inbox) {
try {
await sendUndoLike(inbox, likeRow.id, likeRow.object, username);
console.log(`π [${username}] Undo Like μ μ‘ -> ${inbox}`);
} catch (e: any) {
console.log(`β Undo Like μ€ν¨, λ‘μ»¬λ§ μμ : ${e.message}`);
}
}
if (likeRow) {
db.prepare(`DELETE FROM likes WHERE id=?`).run(likeRow.id);
} else {
// idλ‘ μ§μ λ§€μΉ μλλ©΄ shortId LIKEλ‘ νλ² λ μμ
// If direct matching by ID fails, perform an additional deletion using `shortId LIKE`.
db.prepare(`DELETE FROM likes WHERE actor=? AND object LIKE '%' ||?|| '%'`).run(myActor, shortId);
}
const c = (db.prepare(`SELECT COUNT(*) as c FROM likes WHERE object LIKE '%' ||?|| '%'`).get(shortId) as any).c;
console.log(`π v1-unfavourite`);
return NextResponse.json({
id: id,
favourited: false,
favourites_count: c,
reblogged: false,
});
}
βοΈ app/api/v1/statuses/[id]/reblog/route.ts – κΉνλΈ μ°Έμ‘° / Refer to GitHub.
βοΈ app/api/v1/statuses/[id]/unreblog/route.ts – κΉνλΈ μ°Έμ‘° / Refer to GitHub.
π ν λ§ λ³κ²½ μμ / Theme Change Modification
βοΈ ν
λ§κ° λ³κ²½λμ΄λ Pinafore ν
λ§λ‘ κ³ μ λλ λ¬Έμ λ₯Ό μμ νμ΅λλ€.
Fixed an issue where the theme remained locked to “Pinafore” even after the theme was changed.
βοΈ μλ‘μ΄ ν
λ§λ₯Ό λ§λ€μ΄μ μ
λ‘λνλ©΄ ν
λ§νμΌμ΄ μ‘΄μ¬νλμ§ νμΈνκ³ index.tsνμΌμ μμ±ν©λλ€.
When you create and upload a new theme, the system checks for the existence of the theme file and generates an index.ts file.
βοΈ ν
λ§λλ ν 리 λͺ
μ μμ λ‘κ² μμ κ°λ₯νκ³ ν
λ§λλ ν 리 λ΄μ νμΌμ΄λ¦μ λͺ¨λ λμΌνκ² μ μ©ν΄μΌν©λλ€.
You are free to rename the theme directory, but the filenames within the directory must remain exactly the same.
βοΈ ν
λ§λ₯Ό μμ νλ κ²½μ°λ κ΄λ¦¬μ λͺ¨λμμ μμ νλλ‘ μ²λ¦¬ν μμ μ
λλ€.
We plan to handle theme deletion via the administrator mode.
— lib/watchThemas.ts
for (const folderName of folders) {
... ...
// write code to index.ts themeName.ts files
const indexContent = `'use client';\n ${imports}
export const themeRegistry = {
${registry}} as const;
export type ThemeName = keyof typeof themeRegistry;
export function getThemeComponent(name: ThemeName) {
return themeRegistry[name]?.component;
}
`;
const namesContent = `export const themeNames = ${JSON.stringify(validFolders)} as const;
export type ThemeName = typeof themeNames[number];
`;
}
... ...
// β
myapp60
if (process.env.NODE_ENV!== 'production') {
let t: NodeJS.Timeout;
chokidar.watch(themesDir, {
depth: 1,
ignoreInitial: true,
ignored: (p: string) => {
const b = path.basename(p);
return b === 'index.ts' || b === 'themeNames.ts';
}
}).on('all', (event) => {
// Check addDir, unlinkDir, add, unlink
clearTimeout(t);
t = setTimeout(generate, 1000); // β
1μ΄ λκΈ° / Wait 1 second
});
}
π νμ΄μ§ λ€μ΄μ ν μ€νΈ / Pagination Test
βοΈ μμλΌμ°νΈ μμ± / Create temporary route
— νλ²μ λ§μ κΈμ μ°κ³ λμμμ νλ λΌμ°νΈ μ
λλ€.
This is a route for writing multiple posts at once and deleting them simultaneously.
1)api/test/bulk/route.ts
# 1. κΈ 50κ° μμ± (λ‘κ·ΈμΈ μνμμ) / Generate 50 posts (while logged in)
curl -X POST https://aloy-horizon.duckdns.org/api/test/bulk \
-H "Content-Type: application/json" \
-b "refresh_token=YOUR_TOKEN" \
-d '{"count":50}'
# 2. μμ / delete
curl -X DELETE https://aloy-horizon.duckdns.org/api/test/bulk \
-H "Content-Type: application/json" \
-b "refresh_token=YOUR_TOKEN" \
— 리λͺ¨νΈ μλ²κΈ κ°μ μμ λΌμ°νΈ
Route for forced deletion of remote server posts
1) api/test/nuke-remote/route.ts
2) κΈ°λ³Έμ μΌλ‘ λ‘컬μλ²μ 리λͺ¨νΈ μλ²μ κΈμ΄ μΌμΉνμ§ μμΌλ©΄ μμ ν μ μμ΅λλ€.
By default, you cannot delete the content if the versions on the local server and the remote server do not match.
3) μλλ λ‘컬μλ² κΈμ μλλ° λ¦¬λͺ¨νΈ μλ²λ§ κΈμ΄ λ¨μ μλκ²½μ° μμ ν μ μλ λΌμ°νΈ μ
λλ€.
The following is a route that allows you to delete posts that exist only on the remote server but not on the local server.
4) μμλ‘ μ¬μ©νκ³ νλ‘λμ
μμλ μμ ν΄μΌν©λλ€.
It should be used temporarily and removed in the production environment.
# 1. μν νμΈ / Check Status
curl -b "refresh_token=YOUR_TOKEN" https://aloy-horizon.duckdns.org/api/test/nuke-remote
# 2. κΈ μμ / post delete
curl -X DELETE -b "refresh_token=YOUR_TOKEN" \
"https://aloy-horizon.duckdns.org/api/test/nuke-remote?id=01913d88-51bd-4564-9f0a-820ec7e9fb0e"
βοΈ μ½λ μμ / Code Modification
— app/usersui/[username]/ClientPage.tsx
// β
myapp60
// - νμ΄μ§λ€μ΄μ
μ TimelineListκ° λ΄λΉ, μ¬κΈ°μ λΉ λ°°μ΄λ‘ μμ
// - Pagination is handled by TimelineList; start with an empty array here.
useEffect(() => {
if (!username) return;
setTimeline([]);
setLoading(false);
}, [username]);
const refreshTimeline = async () => {
if (!username) return;
const json = await fetch(`/api/v1/timelines/home?limit=20&format=cursor&t=${Date.now()}`, { cache: 'no-store', credentials: 'include' }).then(r => r.json());
const updated = Array.isArray(json)? json : json.data;
setTimeline(updated || []);
};
— app/usersui/[username]/_components/themes/themeex/MainTheme.tsx
# λͺ¨λ ν
λ§νμΌμ΄λ¦μ MainTheme.tsxλ‘ λ³κ²½
# Rename all theme files to MainTheme.tsx.
— app/usersui/[username]/ClientPage.tsx
const {
flatData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useCursorPagination({
key: ['timeline', tab],
initialData: initialTimeline.slice(0, 20), // β
myapp60
fetcher: async (cursor) => {
const params = new URLSearchParams();
params.set('limit', '20');
if (cursor) params.set('max_id', cursor);
const res = await fetch(`/api/v1/timelines/${tab}?${params.toString()}`, { credentials: 'include' });
if (!res.ok) throw new Error('fetch fail');
const json = await res.json();
const arr = Array.isArray(json)? json : (json.data || json.statuses || []);
const withMine = arr.map((p:any) => ({
...p,
isMine: p.username === currentUser || p.account?.username === currentUser || p.acct === currentUser
}));
return {
data: withMine,
nextCursor: withMine.length? withMine[withMine.length-1].id : null,
hasMore: withMine.length >= 20
};
},
});
// β
myapp60 - flatDataλ§ μ¬μ©, initialTimeline μ€λ³΅ μ¬μ© μ ν¨
const flat = (flatData || []).flat().filter(Boolean) as any[];
const data = flat.length > 0? flat : initialTimeline.filter((p:any) => p && p.id);
... ...
{data.filter((p:any) => p && p.id).map((p: any, idx: number) => {
const uniqueKey = `${p.id}-${idx}`;
const isMine = !!(currentUser && (
p.isMine || p.username === currentUser || p.acct === currentUser || p.account?.username === currentUser
));
return (
... ...
{currentUser && isMine && (
<button onClick={() => onDelete(p.id)} className="text-[#e0245e] ml-auto text-xs hover:underline">
π Delete
</button>
)}
... ...
);
})}
π ν μ€νΈ / Test
βοΈ νμ΄μ§λ€μ΄μ +ν λ§ / Pagination + Theme

βοΈμ’μμ + λΆμ€νΈ(λ‘컬) / Like + Boost (Local)

βοΈμ’μμ + λΆμ€νΈ / Like + Boost(GTS)

λ§ν볡μ 21μ₯ 22μ / Matthew 21: 22
“λν¬κ° κΈ°λν λμ 무μμ΄λ μ§ λ―Ώκ³ κ΅¬νλ κ²μ λ€ λ°μΌλ¦¬λΌ νμλλΌ”
“And everything you ask in prayer, believing, you will receive.”