๐๐ป myapp48์์๋ ๋ก์ปฌ SNSํ์ด์ง์์ ๊ธ์ฐ๊ธฐ ํผ์ ์ถ๊ฐํฉ๋๋ค.
In myapp48, a post creation form is added to the local social media page.
๐๐ป ์์ฑํ ๊ธ์ด public / unlisted / private๋ก ๊ตฌ๋ถํด์ ์ ์ฅ๋๋๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ
์ด๋ธ์ ์์ ํฉ๋๋ค.
Modify the database table so that written posts are saved with a classification of public, unlisted, or private.
๐๐ป ์ ์ฒด ์ฝ๋๋ ๊นํ๋ธ์์ ํ์ธ ํ ์ ์์ต๋๋ค.
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
โ โโโ 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
โ โโโ 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/route.ts -> timeline
โ โโโ oauth/authorize/route.ts -> auth
โ โโโ oauth/token/route.ts -> auth
โ โโโ api/v1/search/home/route.ts -> search
โ โโโ api/v2/search/home/route.ts -> search
โ โโโ 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/auth/signup/route.ts -> Signup
โ โโโ api/auth/login/route.ts -> Login
โ โโโ api/auth/logout/route.ts -> 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
โ โ โโโ pinafore/PinaforeTheme.tsx -> Theme 1
โ โ โโโ pinafore/FollowersList.tsx -> Theme 1, FollowersList
โ โ โโโ pinafore/FollowingList.tsx -> Theme 1, FollowingList
โ โ โโโ pinafore/NotificationsList.tsx -> Theme 1, NotificationsList
โ โ โโโ 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
โโโ package.json
๐ ํ๋ก์ ํธ ์์ / Project Start
๐ visibility
| ๊ธ ์ํ | ๊ฒ์คํธ | ๋ก๊ทธ์ธ(๋จ) | ํ๋ก์ |
|---|---|---|---|
| public | O | O | O |
| unlisted | X (ํ๋กํ) | O (ํ๋กํ) | O / ํ์ ์ค์ ์ ๋ฐ๋ผ |
| private | X | X | O (๋ณธ์ธ+ํ๋ก์) |
๐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ
์ด๋ธ ์์
Modify database table
— ์๋์ SQL๋ฌธ์ Sqlite์์ ํ์ค์ฉ ์คํํฉ๋๋ค.
Execute the SQL statements below in SQLite one line at a time.
-- 1. posts์ visibility + ํ์ํ ์ปฌ๋ผ ์ถ๊ฐ
Add visibility and necessary columns to the `posts` table.
ALTER TABLE posts ADD COLUMN visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct'));
ALTER TABLE posts ADD COLUMN original_id TEXT;
ALTER TABLE posts ADD COLUMN sensitive INTEGER DEFAULT 0;
-- 2. inbox_posts์ visibility + type + sensitive ์ถ๊ฐ (๋ฆฌ๋ชจํธ ๊ธ ๊ตฌ๋ถ)
Added `visibility`, `type`, and `sensitive` to `inbox_posts` (to distinguish remote posts).
ALTER TABLE inbox_posts ADD COLUMN visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct'));
ALTER TABLE inbox_posts ADD COLUMN type TEXT DEFAULT 'Note';
ALTER TABLE inbox_posts ADD COLUMN sensitive INTEGER DEFAULT 0;
-- 3. outbox์๋ visibility ์ถ๊ฐ (๋ฐฐ๋ฌํ ๋ to/cc ๊ตฌ๋ถ์ฉ)
Added visibility to the outbox as well (to distinguish between 'To' and 'CC' during delivery).
ALTER TABLE outbox ADD COLUMN visibility TEXT DEFAULT 'public';
-- 4. ์ธ๋ฑ์ค ๋ณด๊ฐ (ํ์๋ผ์ธ ์๋)
Index Reinforcement (Timeline Speed)
CREATE INDEX IF NOT EXISTS idx_posts_visibility ON posts(visibility);
CREATE INDEX IF NOT EXISTS idx_inbox_visibility ON inbox_posts(visibility);
CREATE INDEX IF NOT EXISTS idx_outbox_visibility ON outbox(visibility);
-- ํ์ธ/Check
.schema posts
.schema inbox_posts
.schema outbox
— ๋ณ๊ฒฝ๋ ์คํค๋ง / Modified schema
sqlite> .schema posts
CREATE TABLE posts (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
, username TEXT DEFAULT 'user1', visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct')), original_id TEXT, sensitive INTEGER DEFAULT 0);
CREATE INDEX idx_posts_username_created ON posts(username, created_at DESC);
CREATE INDEX idx_posts_visibility ON posts(visibility);
sqlite> .schema inbox_posts
CREATE TABLE inbox_posts (
id TEXT PRIMARY KEY,
actor TEXT,
content TEXT,
username TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
, original_id TEXT, visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct')), type TEXT DEFAULT 'Note', sensitive INTEGER DEFAULT 0);
CREATE INDEX idx_inbox_username_created ON inbox_posts(username, created_at DESC);
CREATE INDEX idx_inbox_actor ON inbox_posts(actor);
CREATE INDEX idx_inbox_visibility ON inbox_posts(visibility);
sqlite> .schema outbox
CREATE TABLE outbox (
id TEXT PRIMARY KEY,
type TEXT,
actor TEXT,
object TEXT,
created_at INTEGER
, visibility TEXT DEFAULT 'public');
CREATE INDEX idx_outbox_visibility ON outbox(visibility);
๐ ํ์ผ ์ถ๊ฐ / Add file – visibility
โ๏ธ lib/visibility.ts
— ์ฟ ํค์ ์๋ ๊ฐ์ผ๋ก sessionํ
์ด๋ธ์ ์ ์ ๋ฅผ ๊ฒ์ํ๊ณ SNSํ์ด์ง์ ๊ธ์ public’ | ‘unlisted’ | ‘private’ ๋ณ๋ก ํํฐ๋ง ํ๋ ๊ธฐ๋ฅ์
๋๋ค.
This feature searches for a user in the session table using a value from a cookie and filters posts on the SNS page based on visibility settings: ‘public’, ‘unlisted’, or ‘private’.
— ์์ธํ ์ฝ๋๋ด์ฉ์ ๊นํ๋ธ ์ฝ๋๋ฅผ ์ฐธ์กฐ ํ์ธ์
Please refer to the GitHub repository for detailed code.
export type Visibility = 'public' | 'unlisted' | 'private' | 'direct';
function decodeJwtUsername(jwt: string): string | null { ... }
export function getViewerFromRequest(req: Request): string | null { ... }
export function isFollower(viewer: string, author: string): boolean { ... }
export function canSee(viewer: string | null, post: any): boolean { ... }
๐ ์ฝ๋ ์์ / Code Modification – visibility ์ ์ฅ ๋ฐ ๊ธฐ์กด ๊ธฐ๋ฅ ํ์ธ
โ๏ธ lib/db.ts
— ๋ณ๊ฒฝ๋ ์คํค๋ง์ ๋ง๊ฒ ์ฝ๋๋ฅผ ์์ ํฉ๋๋ค.
Modify the code to match the updated schema.
db.exec(`
... ...
-- โ
myapp48 posts - visibility ์ถ๊ฐ! (public/unlisted/private/direct)
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
username TEXT DEFAULT 'user1',
visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct')),
sensitive INTEGER DEFAULT 0,
original_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
... ...
-- โ
myapp48 inbox_posts - visibility + type ์ถ๊ฐ/Added
CREATE TABLE IF NOT EXISTS inbox_posts (
id TEXT PRIMARY KEY,
original_id TEXT,
actor TEXT,
content TEXT,
username TEXT,
visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct')),
type TEXT DEFAULT 'Note',
sensitive INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
... ...
-- โ
myapp46 outbox - visibility ์ถ๊ฐ/added!
CREATE TABLE IF NOT EXISTS outbox (
id TEXT PRIMARY KEY,
type TEXT,
actor TEXT,
object TEXT,
visibility TEXT DEFAULT 'public',
created_at INTEGER
);
... ...
`);
db.exec(`
... ...
-- posts
CREATE INDEX IF NOT EXISTS idx_posts_username_created ON posts(username, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_posts_visibility ON posts(visibility);
-- inbox_posts
CREATE INDEX IF NOT EXISTS idx_inbox_username_created ON inbox_posts(username, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_inbox_actor ON inbox_posts(actor);
CREATE INDEX IF NOT EXISTS idx_inbox_visibility ON inbox_posts(visibility);
... ...
-- outbox
CREATE INDEX IF NOT EXISTS idx_outbox_visibility ON outbox(visibility);
`);
โ๏ธapp/api/v1/statuses/route.ts – remote server
— ์ธ๋ถ ์ฑ์์ ๋ด ์๋ฒ์ ๊ธ ์์ฑํ ๋ visibility ํ๋ ์ ์ฉ
Apply the visibility field when posting to my server from an external app.
... ...
export async function POST(req: Request) {
... ...
/*-- ํ
์ด๋ธ ์์ฑ ๋ถ๋ถ ์ญ์ / Delete the table creation section. */
// posts ์ ์ฅ / save posts
// โ
myapp48 - add visibility
try {
db.prepare(`INSERT INTO posts (id, content, created_at, username, visibility) VALUES (?,?,?,?,?)`)
.run(id, content, now, username, visibility);
} catch (e) {
console.error('posts ์ ์ฅ ์๋ฌ / posts save error', e);
}
//โ
myapp48 - to,cc ๊ตฌ๋ถ / to,CC distinction
const followersUrl = `https://${DOMAIN}/users/${username}/followers`;
let to: string[] = [];
let cc: string[] = [];
if (visibility === 'public') {
to = ['https://www.w3.org/ns/activitystreams#Public'];
cc = [followersUrl];
} else if (visibility === 'unlisted') {
to = [followersUrl];
cc = ['https://www.w3.org/ns/activitystreams#Public'];
} else if (visibility === 'private') {
to = [followersUrl];
cc = [];
}
// โ
myapp48
const note = {
id: `https://${DOMAIN}/users/${username}/statuses/${id}`,
type: 'Note',
content: `<p>${content}</p>`,
attributedTo: `https://${DOMAIN}/users/${username}`,
published: new Date(now).toISOString(),
to,
cc,
};
/*-- ํ
์ด๋ธ ์์ฑ ๋ถ๋ถ ์ญ์ / Delete the table creation section. */
// outbox ์ ์ฅ
// โ
myapp48 - add visibility
try {
db.prepare(`INSERT INTO outbox (id, type, actor, object, created_at, visibility) VALUES (?,?,?,?,?,?)`)
.run(randomUUID(), 'Create', `https://${DOMAIN}/users/${username}`, JSON.stringify(note), now, visibility);
} catch (e) {
console.error('outbox ์ ์ฅ ์๋ฌ', e);
}
... ...
}
... ...
โ๏ธapp/api/posts/route.ts – local server
— ๋ก์ปฌ ์๋ฒ ๊ธ์ฐ๊ธฐ ํ ๋ visibility ํ๋ ์ ์ฉ
Apply the visibility field when writing on the local server.
... ...
// โ
myapp48
import { getViewerFromRequest, canSee } from '@/lib/visibility';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
let targetUsername = searchParams.get('username'); // โ
myapp48
// โ
myapp48 - viewer / target ๋ถ๋ฆฌ (๊ธฐ์กด ์ฝ๋๋ token ์์ผ๋ฉด username ๋ฎ์ด์จ์ ํ๋กํ ์กฐํ๊ฐ ๋ง๊ฐ์ง)
// targetUsername = ํ๋กํ ์ฃผ์ธ, viewer = ์ง๊ธ ๋ก๊ทธ์ธํ ์ฌ๋
const viewer = getViewerFromRequest(req); // login user
const profileOwner = targetUsername; // search user
// โ
myapp48 - target ์ฒดํฌ
if (!profileOwner) return NextResponse.json({ error: 'username required' }, { status: 400 });
const allPosts = db.prepare('SELECT * FROM posts WHERE username =? ORDER BY created_at DESC').all(profileOwner) as any[];
// โ
myapp48 - visibility ํํฐ๋ง ์ถ๊ฐ
// public: ๋๊ตฌ๋ / unlisted: ๋ก๊ทธ์ธ ํ์ / private: ๋ณธ์ธ+ํ๋ก์๋ง
const isOwnerView = viewer && viewer === profileOwner;
const filtered = allPosts.filter((p: any) => {
if (isOwnerView) return true; // ๋ณธ์ธ์ด๋ฉด ์ ๋ถ ๋ณด๊ธฐ
return canSee(viewer, p);
});
return NextResponse.json(filtered);
}
export async function POST(req: Request) {
... ...
// โ
myapp48 - visibility default value
visibility = visibility || 'public';
... ...
// โ
myapp48 - add visibility
const id = randomUUID();
const now = Date.now();
//db.prepare('INSERT INTO posts (id, content, username) VALUES (?, ?, ?)').run(id, content, username);
db.prepare('INSERT INTO posts (id, content, username, created_at, visibility) VALUES (?,?,?,?,?)').run(id, content, username, now, visibility);
// โ
myapp48
// - to/cc distinction
const followersUrl = `https://${DOMAIN}/users/${username}/followers`;
let to: string[] = [];
let cc: string[] = [];
if (visibility === 'public') {
to = ['https://www.w3.org/ns/activitystreams#Public'];
cc = [followersUrl];
} else if (visibility === 'unlisted') {
to = [followersUrl];
cc = ['https://www.w3.org/ns/activitystreams#Public'];
} else if (visibility === 'private') {
to = [followersUrl];
cc = [];
}
// โ
myapp48
const noteId = `https://${DOMAIN}/users/${username}/posts/${id}`;
const note = {
id: noteId,
type: 'Note',
attributedTo: `https://${DOMAIN}/users/${username}`,
content: content.startsWith('<p>')? content : `<p>${content}</p>`,
published: new Date().toISOString(),
to,
cc
};
// โ
myapp48 - add visibility
try {
db.prepare('INSERT INTO outbox (id, type, actor, object, created_at, visibility) VALUES (?,?,?,?,?,?)')
.run(randomUUID(), 'Create', `https://${DOMAIN}/users/${username}`, JSON.stringify(note), now, visibility);
} catch (e) {
console.error('outbox ์ ์ฅ ์๋ฌ', e);
}
... ...
}
... ...
โ๏ธ app/api/timeline/route.ts – local UI
— ๋ด ์๋ฒ UIํ์ด์ง์์ visibility ์ ์ฉ
Apply visibility on my server UI page.
... ...
// โ
myapp48
import { getViewerFromRequest, canSee } from '@/lib/visibility';
export async function GET(req: Request) {
... ...
// โ
myapp48
const viewer = getViewerFromRequest(req);
const isOwnerView = viewer && viewer === username;
... ...
// โ
myapp48 - visibility ์ปฌ๋ผ ์ถ๊ฐ ์กฐํ ( visibility ๊ฒ์)
const rawTimeline = db.prepare(`
SELECT id, content, username, username as actor, created_at, visibility, 'mine' as source, id as original_id
FROM posts WHERE username =?
UNION ALL
SELECT id, content, username, actor, created_at, visibility, 'inbox' as source, original_id
FROM inbox_posts WHERE username =?
ORDER BY created_at DESC
LIMIT 80
`).all(username, username) as any[];
// โ
myapp48 - visibility ํํฐ๋ง ๋ก์ง ์ถ๊ฐ
// public: ๋๊ตฌ๋ / unlisted: ๋ก๊ทธ์ธ ํ์ / private: ๋ณธ์ธ + ํ๋ก์๋ง
const timeline = rawTimeline.filter((p: any) => {
// ๋ด ํ์๋ผ์ธ์ ๋ด๊ฐ ๋ณด๋ ๊ฒฝ์ฐ -> ์ ๋ถ ๋ณด์ฌ์ค (private ํฌํจ)
if (isOwnerView) return true;
// ๋จ์ ํ์๋ผ์ธ์ ๋ณด๊ฑฐ๋ ๊ฒ์คํธ์ธ ๊ฒฝ์ฐ -> canSee๋ก ํํฐ
return canSee(viewer, p);
});
}
โ๏ธ app/api/v1/timelines/home/route.ts – External UI(etc. Pinafore)
— ์ธ๋ถ์ฑ์์ ๋ด ์๋ฒ ํ์๋ผ์ธ ๋ณผ๋ visibility ์ ์ฉ
Apply visibility settings when viewing my server’s timeline from an external app.
// โ
myapp48 - visibility ๊ณตํต ํฌํผ import ์ถ๊ฐ
import { getViewerFromRequest, canSee } from '@/lib/visibility';
... ...
export async function GET(req: Request) {
... ...
// โ
myapp48
const viewer = getViewerFromRequest(req);
const username = viewer || 'user1'; // ๊ฒ์คํธ๋ฉด ๋น ํ์๋ผ์ธ ๋ฐํ๋๋๋ก ์๋์์ ์ฒ๋ฆฌ
// โ
myapp48 - ๋น๋ก๊ทธ์ธ์ด๋ฉด ํ ํ์๋ผ์ธ ์ ๊ทผ ์ฐจ๋จ (๋ง์คํ ๋ ์คํ)
// Access to the home timeline is blocked for non-logged-in users (Mastodon specification).
if (!viewer) {
return NextResponse.json([], { headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' } });
}
... ...
// โ
myapp48 - visibility ํํฐ๋ง ์ ์ฉ
// ์ ์ฑ
: public = ๋๊ตฌ๋, unlisted = ํ์์๋ ๋ณด์ฌ์ค (ํ๋กํ์์๋ง ์จ๊ธฐ๋ ค๋ฉด false๋ก ๋ณ๊ฒฝ), private = ๋ณธ์ธ+ํ๋ก์๋ง
// Policy: public = visible to everyone, unlisted = visible on Home (change to false to hide only from profile), private = visible only to self and followers
const rawRows = [...myPosts,...inboxPosts];
const filteredRows = rawRows.filter((p: any) => {
const vis = p.visibility || 'public';
if (vis === 'public') return true;
if (vis === 'unlisted') {
// ๋ง์คํ ๋ ์ ์์ ํ์ unlisted ํ์ํจ.
return true; // <- false๋ก ๋ฐ๊พธ๋ฉด ํ์์ unlisted ์จ๊น
}
if (vis === 'private') {
return canSee(viewer, p);
}
return true;
});
filteredRows.sort((a,b) => parseDate(b.created_at) - parseDate(a.created_at));
... ...
}
โ๏ธapp/users/[username]/inbox/route.ts
— ๊ธ ์ ์ฅ์ visibility ํ๋ ์ถ๊ฐ / Add a visibility field when saving the post.
... ...
export async function POST(req: Request, { params }: { params: Promise<{ username: string }> }) {
... ...
if (body.type === 'Create') {
... ...
// โ
myapp48 - for visibility
const to = note.to || [];
const cc = note.cc || [];
const isPublicInTo = Array.isArray(to)? to.includes('https://www.w3.org/ns/activitystreams#Public') : to === 'https://www.w3.org/ns/activitystreams#Public';
const isPublicInCc = Array.isArray(cc)? cc.includes('https://www.w3.org/ns/activitystreams#Public') : cc === 'https://www.w3.org/ns/activitystreams#Public';
let visibility = 'public';
if (!isPublicInTo &&!isPublicInCc) visibility = 'private';
else if (!isPublicInTo && isPublicInCc) visibility = 'unlisted';
else visibility = 'public';
console.log(` -> visibility: ${visibility} to=${JSON.stringify(to)} cc=${JSON.stringify(cc)}`);
// db.prepare(`INSERT OR IGNORE INTO inbox_posts (id, actor, content, username, original_id, created_at) VALUES (?,?,?,?,?,?)`)
// .run(shortId, actorId, content, username, longId, note.published || new Date().toISOString());
// โ
myapp48 - add visibility
db.prepare(`INSERT OR IGNORE INTO inbox_posts (id, actor, content, username, original_id, created_at, visibility, type) VALUES (?,?,?,?,?,?,?,?)`)
.run(shortId, actorId, content, username, longId, note.published || new Date().toISOString(), visibility, note.type || 'Note');
... ...
}
... ...
}
โ๏ธ app/usersui/[username]/_components/themes/pinafore/PinaforeTheme.tsx
— ๊ธ์ฐ๊ธฐ ํผ ์ ์ฉ / Apply writing form
... ...
export function PinaforeTheme({ timeline : initialTimeline, username, initialView, onBoost, onLike }: any) {
... ...
// โ
myapp48 - writing status
const [composeText, setComposeText] = useState('');
const [composeVis, setComposeVis] = useState<'public'|'unlisted'|'private'>('public');
const [posting, setPosting] = useState(false);
... ...
// โ
myapp48 : write post
const handlePost = async () => {
if (!composeText.trim() || posting) return;
setPosting(true);
try {
// ํ ํฐ ์์ผ๋ฉด ํค๋์ ๋ฃ๊ณ ์์ผ๋ฉด ์ฟ ํค ์ธ์ฆ์ผ๋ก!
const token = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
const res = await fetch('/api/v1/statuses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token? { Authorization: `Bearer ${token}` } : {})
},
credentials: 'include',
body: JSON.stringify({ status: composeText, visibility: composeVis })
});
if (res.ok) {
const newPost = await res.json();
setComposeText('');
// ํ์๋ผ์ธ ๋งจ ์์ ์ฆ์ ์ถ๊ฐ!
setTimeline((prev: any[]) => [{
id: newPost.id, content: newPost.content, actor: `https://${DOMAIN}/users/${username}`,
username: username, created_at: new Date().toISOString(), source: 'local', isMine: true, visibility: composeVis
},...prev]);
setCounts(c => ({...c, posts: c.posts + 1}));
} else alert('๊ฒ์ ์คํจ');
} catch (e) { console.error(e); alert('๊ฒ์ ์คํจ'); }
setPosting(false);
};
... ...
return (
<>
<style>{`
... ...
/* โ
myapp48 - composer(write form) */
.pinafore-composer { margin-top: auto; background: white; border: 1px solid #e6ecf0; border-radius: 12px; padding: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.pinafore-composer textarea { width: 100%; height: 80px; resize: none; border: 1px solid #e6ecf0; border-radius: 8px; padding: 8px; font-size: 14px; outline: none; }
.pinafore-composer textarea:focus { border-color: #6364ff; }
.composer-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
.composer-foot select { padding: 4px 6px; font-size: 12px; border-radius: 6px; }
.composer-foot button { background: #6364ff; color: white; border: none; padding: 6px 16px; border-radius: 20px; font-weight: bold; cursor: pointer; font-size: 13px; }
.composer-foot button:disabled { opacity: 0.4; cursor: not-allowed; }
... ...
`}</style>
{/* โ
myapp48 - ์ข์ธก ํ๋จ ๊ธ์ฐ๊ธฐ ํผ(๋ก๊ทธ์ธ ํ) / Writing form at the bottom left (after logging in) */}
{isMe && (
<div className="pinafore-composer">
<textarea
value={composeText}
onChange={(e) => setComposeText(e.target.value)}
placeholder="๋ฌด์จ ์ผ์ด ์ผ์ด๋๊ณ ์๋์?"
maxLength={500}
/>
<div className="composer-foot">
<select value={composeVis} onChange={(e) => setComposeVis(e.target.value as any)}>
<option value="public">๐ ๊ณต๊ฐ/public</option>
<option value="unlisted">๐ ๋ฏธ๋ฑ๋ก/unlisted</option>
<option value="private">๐ ํ๋ก์๋ง/private</option>
</select>
<button onClick={handlePost} disabled={posting ||!composeText.trim()}>
{posting? '...' : 'submit'}
</button>
</div>
</div>
)}
... ...
</>
)
}
๐ ์ฝ๋ ์์ – ํ์๋ผ์ธ์์ visibility์ ๋ฐ๋ผ ๋ณด์ฌ์ฃผ๊ธฐ
โ๏ธ visibility์ ๋ฐ๋ผ ๊ตฌ๋ถํด์ ๋ณด์ฌ์ฃผ๋ ๋ถ๋ถ์ ์๋์ ํ์ผ ๋ถ๋ถ์
๋๋ค.
The section that displays content based on visibility is the file section below.
— lib/visibility.ts – visibility ํจ์ / function
— app/api/posts/route.ts ํ๋กํ ํ์๋ผ์ธ / profile timeline
— app/api/timelines/home/route.ts – ํ์๋ผ์ธ API / Timeline API
๐ ํ ์คํธ / Test
โ๏ธ user1์ผ๋ก ๋ก๊ทธ์ธํ๊ณ @user1ํ์ด์ง ๋ฐฉ๋ฌธ
Log in as user1 and visit the @user1 page.

โ๏ธ user2๋ก ๋ก๊ทธ์ธํ๊ณ @user1ํ์ด์ง ๋ฐฉ๋ฌธ
Log in as user2 and visit @user1’s page.

โ๏ธ ๋ก๊ทธ์์ํ๊ณ @user1ํ์ด์ง ๋ฐฉ๋ฌธ
Log out and visit @user1’s page.

โ๏ธ pinafore – user1@freelifemakers.com์ผ๋ก ๋ก๊ทธ์ธ
pinafore – Log in as user1@freelifemakers.com

โ๏ธ DB ํ์ธ / Check DB
myapp48 % sqlite3 data.sqlite
SQLite version 3.51.0 2025-06-12 13:14:41
Enter ".help" for usage hints.
sqlite> SELECT id,visibility,content FROM posts ORDER BY created_at DESC LIMIT 3
...> ;
8ae0380a-6c14-4507-8caf-cf0411264cb6|unlisted|unlisted test
a2435541-db09-4b65-ad08-ae90d84dffdd|private|private test
cd3f6242-9b9b-4000-b648-0040e9dcbf40|public|form test
sqlite>
โ๏ธ ๊ธ ์ง์ ์ญ์ / Delete post directly
curl -X DELETE "https://aloy-horizon.duckdns.org/api/cleanup-orphan?id=cd3f6242-9b9b-4000-b648-0040e9dcbf40&username=user1"
์ํ๋ณต์ 8์ฅ 32์ / John 8:32
“๊ทธ๋ฆฌ๊ณ ๋ํฌ๋ ์ง๋ฆฌ๋ฅผ ์๊ฒ ๋ ๊ฒ์ด๋ฉฐ, ์ง๋ฆฌ๊ฐ ๋ํฌ๋ฅผ ์์ ๋กญ๊ฒ ํ ๊ฒ์ด๋ค.”
“Then you will know the truth ,and the truth will set you free”