ππ» myapp51μμλ κΈ μμ±μ μ΄λ―Έμ§ μ
λ‘λ λΆλΆμ ꡬνν©λλ€.
In myapp51, we are implementing the image upload feature for creating posts.
ππ» μ
λ‘λλ μ΄λ―Έμ§ κ²½λ‘λ νλ‘μ νΈ λ£¨νΈ/public/uploads/ λλ ν 리 μ
λλ€.
The path for uploaded images is the project root/public/uploads/ directory.
ππ» μ
λ‘λλ μ΄λ―Έμ§λ 리λͺ¨νΈ μλ²λ‘ μ μ‘νμ§ μκ³ μλ³Έ μ£Όμ μ λκ²½λ‘ (note)λΆλΆλ§ μ μ‘ν©λλ€.
Uploaded images are not transmitted to the remote server; only the absolute path to the original source is sent.
ππ» μ 체 μ½λλ κΉνλΈμμ νμΈ ν μ μμ΅λλ€.
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
β βββ api/v1/media/route.ts -> Image upload
β βββ 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
π ν μ΄λΈ μΆκ° / Add table
βοΈ media_attachments : νμΌ μ λ‘λ / file upload
βοΈ post_media : μ λ‘λν νμΌμ κΈμ λΆμ΄κΈ° / Insert the uploaded file into the post.
-- μ΄λ―Έμ§ νμΌ κ²½λ‘ / Image file path
CREATE TABLE IF NOT EXISTS media_attachments (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL,
created_at INTEGER
);
-- postsμμ κΈ νλ μ§μ°λ©΄ post_mediaμ μλ μ°κ²°μ€λ μλμΌλ‘ μ§μμ§
-- media_attachmentsμμ νμΌ νλ μ§μ°λ©΄ post_media μ°κ²°μ€λ μλ μ§μμ§
-- Deleting a post from `posts` automatically removes the corresponding link in `post_media`.
-- Deleting a file from `media_attachments` automatically removes the corresponding link in `post_media`.
CREATE TABLE IF NOT EXISTS post_media (
post_id TEXT NOT NULL,
media_id TEXT NOT NULL,
PRIMARY KEY(post_id, media_id),
FOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY(media_id) REFERENCES media_attachments(id) ON DELETE CASCADE
);
-- μΈλ±μ€ μΆκ° / Add index
CREATE INDEX IF NOT EXISTS idx_media_username ON media_attachments(username);
CREATE INDEX IF NOT EXISTS idx_media_created ON media_attachments(created_at);
CREATE INDEX IF NOT EXISTS idx_post_media_media_id ON post_media(media_id);
π μ½λ μμ λ° λΌμ°νΈ μΆκ° / Code modifications and route additions
βοΈ lib/db.ts
// myapp51 - media
db.exec(`
... ...
CREATE TABLE IF NOT EXISTS media_attachments (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL, -- image/jpeg, image/png
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS post_media (
post_id TEXT NOT NULL,
media_id TEXT NOT NULL,
PRIMARY KEY(post_id, media_id)
);
`);
db.exec(`
... ...
-- image upload
CREATE INDEX IF NOT EXISTS idx_media_username ON media_attachments(username);
CREATE INDEX IF NOT EXISTS idx_media_created ON media_attachments(created_at);
CREATE INDEX IF NOT EXISTS idx_post_media_media_id ON post_media(media_id);
`);
βοΈ app/api/v1/media/route.ts – μ΄λ―Έμ§ μ λ‘λ API μ κ· μΆκ° / Added new image upload API.
// app/api/v1/media/route.ts
// - β
myapp51
import db from '@/lib/db';
import { randomUUID } from 'crypto';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
// public/uploads
const UPLOAD_DIR = path.join(process.cwd(), 'public', 'uploads');
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;
}
// μΏ ν€ / Cookie
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: Request) {
const username = getUser(req);
if (!username) return NextResponse.json({error:'unauthorized'}, {status:401});
const form = await req.formData();
const file = form.get('file') as File;
if (!file) return NextResponse.json({error:'file required'}, {status:400});
if (file.size > 8 * 1024 * 1024) return NextResponse.json({error:'8MB μ΄νλ§'}, {status:400});
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, {recursive:true});
const id = randomUUID();
const ext = file.name.split('.').pop() || 'jpg';
const filename = `${id}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(path.join(UPLOAD_DIR, filename), buffer);
const url = `/uploads/${filename}`;
const fullUrl = `https://${process.env.DOMAIN || 'aloy-horizon.duckdns.org'}${url}`;
db.prepare('INSERT INTO media_attachments (id, username, url, type, created_at) VALUES (?,?,?,?,?)')
.run(id, username, fullUrl, file.type, Date.now());
// Mastodon νΈν μλ΅ / Mastodon-compatible response
return NextResponse.json({ id, url: fullUrl, preview_url: fullUrl, type: 'image' });
}
βοΈ app/api/v1/statuses/route.ts – κΈμ°κΈ° API μμ / Modify Writing API
... ...
export async function POST(req: Request) {
... ...
// β
myapp51
// - image upload
// - 3κ°μ§ μΌμ΄μ€ λͺ¨λ μ§μ(λ§μ€ν λ,λ‘컬,μλλ‘μ΄λμ±) + ν¬λμ λ°©μ§
// - Supports all three scenarios (Mastodon, local, Android app) + crash prevention.
const raw_ids = body.media_ids || body.media?.ids || body.mediaIds || [];
const media_ids: string[] = Array.isArray(raw_ids) ? raw_ids : [];
... ...
// β
myapp51 - image upload , μμ κΆ μ²΄ν¬ ν μ°κ²° / Connect after verifying ownership
// ['a','b'] β ... β 'a', 'b'
let attachments: any[] = [];
if (media_ids.length > 0) {
const placeholders = media_ids.map(() => '?').join(',');
attachments = db.prepare(
`SELECT * FROM media_attachments WHERE id IN (${placeholders}) AND username=?`
).all(...media_ids, username) as any[];
for (const m of attachments) {
try {
db.prepare('INSERT INTO post_media (post_id, media_id) VALUES (?,?)').run(id, m.id);
} catch (e) {
console.error('post_media insert fail', e);
}
}
}
// - url μ λκ²½λ‘ λ³΄μ / Adjusting absolute URL paths
const toAbsoluteUrl = (u: string) => u.startsWith('http') ? u : `https://${DOMAIN}${u}`;
... ...
// β
myapp48 + myapp51
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,
attachment: attachments.map(m => ({ // β
myapp51 - image uload
type: 'Document',
mediaType: m.type,
url: toAbsoluteUrl(m.url)
}))
};
... ...
return NextResponse.json({
id: id,
... ...
favourites_count: 0,
replies_count: 0,
// β
myapp51 - Pinafore/Tusky 미리보기 /
media_attachments: attachments.map(m => ({
id: m.id,
type: 'image',
url: toAbsoluteUrl(m.url),
preview_url: toAbsoluteUrl(m.url)
}))
}, {
headers: { 'Access-Control-Allow-Origin': '*' }
});
}
βοΈ app/usersui/[username]/_components/themes/pinafore/PinaforeTheme.tsx
— UI Themeμμ / Modify UI Theme
// β
myapp51 - ν ν°κ°μ Έμ€κ° , url κ²½λ‘보μ / Retrieve token, adjust URL path
const getToken = () => localStorage.getItem('access_token') || localStorage.getItem('token') || '';
const toAbsolute = (u: string) => {
if (!u) return '';
return u.startsWith('http')? u : `https://${DOMAIN}${u}`;
};
export function PinaforeTheme({ timeline : initialTimeline, username, initialView, onBoost, onLike, onShare }: any) {
... ...
// β
myapp51 - image upload
const [mediaIds, setMediaIds] = useState<string[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
... ...
// β
myapp48 + myapp51 : write post + image
const handlePost = async () => {
... ...
body: JSON.stringify({
status: composeText,
visibility: composeVis,
media_ids: mediaIds // β
myapp51 - add media_ids
})
... ...
if (res.ok) {
const newPost = await res.json();
... ...
media_attachments: newPost.media_attachments || [] // β
myap51 - preview
}
... ...
};
}
// β
myapp51 : image upload
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
if (mediaIds.length + files.length > 4) return alert('μ΄λ―Έμ§λ μ΅λ 4κ°κΉμ§ / Up to 4 images.');
setUploading(true);
const token = getToken();
try {
for(const file of files) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/v1/media', {
method: 'POST',
body: fd,
headers: {...(token? { Authorization: `Bearer ${token}` } : {}) },
credentials: 'include'
});
if (!res.ok) {
const err = await res.json().catch(()=>({}));
throw new Error(err.error || 'upload fail');
}
const data = await res.json();
// data = { id, url, preview_url... }
setMediaIds(prev => [...prev, data.id]);
setPreviews(prev => [...prev, toAbsolute(data.preview_url || data.url)]);
}
} catch (err: any) {
console.error(err);
alert(`μ
λ‘λ μ€ν¨/upload failed: ${err.message}`);
} finally {
setUploading(false);
// κ°μ νμΌ λ€μ μ ν κ°λ₯νλλ‘ reset
e.target.value = '';
}
}
return (
<>
<style>{`
... ...
/* β
myapp51 - composer-choice + preview */
.composer-choice { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
.composer-choice label { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border: 1px solid #e6ecf0; border-radius: 50%; background: white; cursor: pointer; transition: background 0.15s; }
.composer-choice label:hover { background: #f3f4ff; border-color: #6364ff; }
.composer-previews { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 10px; }
.preview-item { position: relative; border-radius: 10px; overflow: hidden; border: 1px solid #e6ecf0; background: #f9fafb; }
.preview-item img { width: 100%; height: 90px; object-fit: cover; display: block; }
.preview-remove { position: absolute; top: 4px;
... ...
`}</style>
{isMe && (
<div className="pinafore-composer">
<textarea
value={composeText}
onChange={(e) => setComposeText(e.target.value)}
placeholder="λ¬΄μ¨ μΌμ΄ μΌμ΄λκ³ μλμ?"
maxLength={500}
/>
{/* β
myapp51 - μ΄λ―Έμ§ μ ν λ²νΌ / Image selection button*/}
<div className="composer-choice">
<label style={{cursor:'pointer', fontSize:18, padding:'2px 6px', border:'1px solid #e6ecf0', borderRadius:6, background:'white'}}>
πΌοΈ
<input type="file" multiple accept="image/*" onChange={onFiles} style={{display:'none'}} />
</label>
</div>
{/* β
myapp51 - image preview */}
{previews.length > 0 && (
<div className="composer-previews">
{previews.map((src, idx) => (
<div key={idx} className="preview-item">
<img src={src} alt="preview" />
<button className="preview-remove" onClick={() => removePreview(idx)}>Γ</button>
</div>
))}
</div>
)}
{uploading && <div style={{fontSize:12, color:'#6364ff', marginTop:6}}>π€ μ
λ‘λ μ€ / uploading...</div>}
<div className="composer-foot">
... ...
</div>{/* /div className=compose-foot */}
</div>
)}
... ...
{timeline.map((p: any) => (
... ...
<div className="status-text" dangerouslySetInnerHTML={{ __html: p.content }} />
{/* β
myapp51 - νμλΌμΈ μ΄λ―Έμ§ λ λ / Timeline image render*/}
{p.media_attachments && p.media_attachments.length > 0 && (
<div className={`status-media ${p.media_attachments.length === 1? 'single' : ''}`}>
{p.media_attachments.map((m: any) => (
<img key={m.id} src={toAbsolute(m.preview_url || m.url)} alt="media" loading="lazy" />
))}
</div>
)}
... ...
))}
);
βοΈ app/api/v1/statuses/[id]/route.ts DELETE
— κΈ μμ λ° μ΄λ―Έμ§ μμ ( λ€μ νλ‘μ νΈ)
Delete Post and Delete Image (Next Project)
π ν μ€νΈ / Test
βοΈ μ΄λ―Έμ§ μ ν / Select image

βοΈ μ΄λ―Έμ§ μ λ‘λ – λ‘컬μλ² / image upload – local server

βοΈ μ΄λ―Έμ§ μ λ‘λ – 리λͺ¨νΈμλ²(κ³ ν¬μμ ) / image upload – remote server(gotosocial)

μν볡μ 8μ₯ 32μ / John 8:32
“κ·Έλ¦¬κ³ λν¬λ μ§λ¦¬λ₯Ό μκ² λ κ²μ΄λ©°, μ§λ¦¬κ° λν¬λ₯Ό μμ λ‘κ² ν κ²μ΄λ€.”
“Then you will know the truth ,and the truth will set you free”