{"id":11244,"date":"2026-09-29T07:52:51","date_gmt":"2026-09-28T22:52:51","guid":{"rendered":"https:\/\/www.freelifemakers.org\/wordpress\/?p=11244"},"modified":"2026-09-29T10:58:23","modified_gmt":"2026-09-29T01:58:23","slug":"nextjs-sns-server-41","status":"publish","type":"post","link":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/2026\/09\/29\/nextjs-sns-server-41\/","title":{"rendered":"[nextjs]SNS Server-41(myapp51)\u00a0"},"content":{"rendered":"\n<p>\ud83d\udc49\ud83c\udffb myapp51\uc5d0\uc11c\ub294 \uae00 \uc791\uc131\uc2dc \uc774\ubbf8\uc9c0 \uc5c5\ub85c\ub4dc \ubd80\ubd84\uc744 \uad6c\ud604\ud569\ub2c8\ub2e4.<br>In myapp51, we are implementing the image upload feature for creating posts.<\/p>\n\n\n\n<p>\ud83d\udc49\ud83c\udffb \uc5c5\ub85c\ub4dc\ub41c \uc774\ubbf8\uc9c0 \uacbd\ub85c\ub294 \ud504\ub85c\uc81d\ud2b8 \ub8e8\ud2b8\/public\/uploads\/ \ub514\ub809\ud1a0\ub9ac \uc785\ub2c8\ub2e4.<br>The path for uploaded images is the <code>project root\/public\/uploads\/<\/code> directory.<\/p>\n\n\n\n<p>\ud83d\udc49\ud83c\udffb \uc5c5\ub85c\ub4dc\ub41c \uc774\ubbf8\uc9c0\ub294 \ub9ac\ubaa8\ud2b8 \uc11c\ubc84\ub85c \uc804\uc1a1\ud558\uc9c0 \uc54a\uace0 \uc6d0\ubcf8 \uc8fc\uc18c \uc808\ub300\uacbd\ub85c (note)\ubd80\ubd84\ub9cc \uc804\uc1a1\ud569\ub2c8\ub2e4.<br>Uploaded images are not transmitted to the remote server; only the absolute path to the original source is sent.<\/p>\n\n\n\n<p>\ud83d\udc49\ud83c\udffb \uc804\uccb4 \ucf54\ub4dc\ub294 \uae43\ud5c8\ube0c\uc5d0\uc11c \ud655\uc778 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.<br>You can find the full code on GitHub.<\/p>\n\n\n\n<p><a href=\"https:\/\/github.com\/gideonslife01\/flm-nextjs\">https:\/\/github.com\/gideonslife01\/flm-nextjs<\/a><\/p>\n\n\n\n<p>\ud83d\udcc1 \uc804\uccb4 \ud504\ub85c\uc81d\ud2b8 \uad6c\uc870 \/ Overall Project Structure<\/p>\n\n\n\n<pre class=\"wp-block-code\" style=\"font-size:14px\"><code>myapp project\/  \n\u251c\u2500\u2500 app\/  (Next.js App Router)\n\u2502   \u251c\u2500\u2500 .well-known\/webfinger\/route.ts  -> webfinger\n\u2502   \u251c\u2500\u2500 api\/follow\/route.ts    -> Follow API(temporary)\n\u2502   \u251c\u2500\u2500 api\/announce\/route.ts  -> Boost(Announcement)\n\u2502   \u251c\u2500\u2500 api\/like\/route.ts.     -> Like API\n\u2502   \u251c\u2500\u2500 api\/posts\/route.ts     -> Writing API\n\u2502   \u251c\u2500\u2500 api\/timeline\/route.ts  -> Timeline API\n\u2502   \u251c\u2500\u2500 api\/followinglist\/route.ts  -> followinglist API\n\u2502   \u251c\u2500\u2500 api\/followerslist\/route.ts  -> followerslist API\n\u2502   \u251c\u2500\u2500 <span style=\"background-color: initial; font-size: inherit; text-align: initial; color: initial;\">api\/v1\/instance<\/span>\/route.ts -> auth\n\u2502   \u251c\u2500\u2500 api\/v1\/apps\/route.ts.    -> auth\n\u2502   \u251c\u2500\u2500 api\/v1\/accounts\/verify_credentials\/route.ts -> auth\n\u2502   \u251c\u2500\u2500 api\/v1\/statuses\/route.ts -> Write Post\n\u2502   \u251c\u2500\u2500 <span style=\"background-color: initial; font-size: inherit; text-align: initial; color: initial;\">api\/v1\/timelines\/home\/route.ts<\/span> -> timeline\n\u2502   \u251c\u2500\u2500 <strong>api\/v1\/media\/route.ts -> Image upload<\/strong>\n\u2502   \u251c\u2500\u2500 <span style=\"background-color: initial; font-size: inherit; text-align: initial; color: initial;\">oauth\/authorize\/route.ts<\/span> -> auth\n\u2502   \u251c\u2500\u2500 <span style=\"background-color: initial; font-size: inherit; text-align: initial; color: initial;\">oauth\/token\/route.ts<\/span> -> auth\n\u2502   \u251c\u2500\u2500 <span style=\"background-color: initial; font-size: inherit; text-align: initial; color: initial;\">api\/v1\/search\/home\/route.ts<\/span> -> search\n\u2502   \u251c\u2500\u2500 <span style=\"background-color: initial; font-size: inherit; text-align: initial; color: initial;\">api\/v2\/search\/home\/route.ts<\/span> -> search\n\u2502   \u251c\u2500\u2500 api\/v1\/accounts\/&#91;id]\/followers\/route.ts -> followers\n\u2502   \u251c\u2500\u2500 api\/v1\/accounts\/&#91;id]\/following\/route.ts -> following\n\u2502   \u251c\u2500\u2500 api\/v1\/statuses\/&#91;id]\/route.ts -> -> Post delete(pinafore)\n\u2502   \u251c\u2500\u2500 api\/auth\/signup\/route.ts        -> Signup\n\u2502   \u251c\u2500\u2500 api\/auth\/login\/route.ts         -> Login\n\u2502   \u251c\u2500\u2500<strong> <\/strong>api\/auth\/logout\/route.ts        -> Logout\n\u2502   \u251c\u2500\u2500<strong> <\/strong>api\/auth\/refresh\/route.ts       -> Refresh Token\n\u2502   \u251c\u2500\u2500<strong> <\/strong>api\/cleanup-orphan\/route.ts     -> post,outbox clean up\n\u2502   \u251c\u2500\u2500<strong> <\/strong>api\/auth\/me\/route.tsx           -> Login Check<strong>\n<\/strong>\u2502   \u251c\u2500\u2500 auth\/signup\/page.tsx            -> Signup UI\n\u2502   \u251c\u2500\u2500 auth\/signup\/page.tsx            -> Login UI\n\u2502   \u251c\u2500\u2500 users\/&#91;username]\/\n\u2502   \u2502   \u251c\u2500\u2500 statuses\/&#91;id]\/route.ts -> Indivisual Post\n\u2502   \u2502   \u251c\u2500\u2500 route.ts               -> Acotr Information\n\u2502   \u2502   \u251c\u2500\u2500 followers\/route.ts     -> Followers List\n\u2502   \u2502   \u251c\u2500\u2500 following\/route.ts     -> Following List\n\u2502   \u2502   \u251c\u2500\u2500 inbox\/route.ts         -> Inbox\n\u2502   \u2502   \u2514\u2500\u2500 outbox\/route.ts        -> outbox\n\u2502   \u251c\u2500\u2500 usersui\/&#91;username]\/\n\u2502   \u2502   \u251c\u2500\u2500 page.tsx               -> Timeline UI(Server, 404)\n\u2502   \u2502   \u251c\u2500\u2500 ClientPage.tsx.        -> Timeline UI(Client,Call Active Themes)\n\u2502   \u2502   \u2514\u2500\u2500 _components\/themes\/\n\u2502   \u2502       \u251c\u2500\u2500 themeex\/ThemeexTheme.tsx   -> Example Theme\n\u2502   \u2502       \u251c\u2500\u2500 pinafore\/PinaforeTheme.tsx -> Theme 1\n\u2502   \u2502       \u251c\u2500\u2500 pinafore\/FollowersList.tsx -> Theme 1, FollowersList\n\u2502   \u2502       \u251c\u2500\u2500 pinafore\/FollowingList.tsx -> Theme 1, FollowingList\n\u2502   \u2502       \u251c\u2500\u2500 pinafore\/NotificationsList.tsx -> Theme 1, NotificationsList\n\u2502   \u2502       \u251c\u2500\u2500 mastodon\/MastodonTheme.tsx -> Theme 2\n\u2502   \u2502       \u2514\u2500\u2500 minimal\/MinimaltTheme.tsx  -> Theme 3\n\u2502   \u251c\u2500\u2500 layout.tsx, page.tsx, globals.css\n\u2502   \u2514\u2500\u2500 favicon.ico\n\u251c\u2500\u2500 lib\/\n\u2502   \u251c\u2500\u2500 theme.tsx              -> Theme Provider\n\u2502   \u251c\u2500\u2500 watchThemes.ts         -> Check real-time theme changes\n\u2502   \u251c\u2500\u2500 auth.ts                -> Authentication, User Management\n\u2502   \u251c\u2500\u2500 ap.ts                  -> Follow,Undo,Create,Likes,Announce\n\u2502   \u251c\u2500\u2500 visibility.ts          -> Visibility(Public,Unlisted,Private,Direct)\n\u2502   \u2514\u2500\u2500 db.ts                  -> DB connection\n\u251c\u2500\u2500 data\/\n\u2502   \u251c\u2500\u2500 keys\/userIDs\/          -> private.pem, public.pem(New)\n\u2502   \u2514\u2500\u2500 keys\/                  -> private.pem, public.pem(legacy)\n\u251c\u2500\u2500 data.sqlite                -> Database(1\/3)\n\u251c\u2500\u2500 data.sqlite-wal            -> Database(2\/3)\n\u251c\u2500\u2500 data.sqlite-shm            -> Database(3\/3)\n\u251c\u2500\u2500 Caddyfile                  -> https \n\u251c\u2500\u2500 instrumentation.ts         -> Background Server\n\u2514\u2500\u2500 package.json<\/code><\/pre>\n\n\n\n<p>\ud83d\udcc1 \ud504\ub85c\uc81d\ud2b8 \uc2dc\uc791 \/ Project Start<\/p>\n\n\n\n<figure class=\"wp-block-embed is-type-wp-embed is-provider-freelifemakers-org wp-block-embed-freelifemakers-org\"><div class=\"wp-block-embed__wrapper\">\n<blockquote class=\"wp-embedded-content\" data-secret=\"qJCwOedvlI\"><a href=\"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/2026\/09\/03\/nextjs-project-summary\/\">[nextjs]\ud504\ub85c\uc81d\ud2b8\uc694\uc57d\/project summary(myapp30+)<\/a><\/blockquote><iframe loading=\"lazy\" class=\"wp-embedded-content\" sandbox=\"allow-scripts\" security=\"restricted\" style=\"position: absolute; visibility: hidden;\" title=\"&#8220;[nextjs]\ud504\ub85c\uc81d\ud2b8\uc694\uc57d\/project summary(myapp30+)&#8221; &#8212; freelifemakers.org\" src=\"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/2026\/09\/03\/nextjs-project-summary\/embed\/#?secret=UF6g9mDu8B#?secret=qJCwOedvlI\" data-secret=\"qJCwOedvlI\" width=\"560\" height=\"315\" frameborder=\"0\" marginwidth=\"0\" marginheight=\"0\" scrolling=\"no\"><\/iframe>\n<\/div><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>\ud83d\udcc1 \ud14c\uc774\ube14 \ucd94\uac00 \/ Add table<\/p>\n\n\n\n<p>\u2714\ufe0f  media_attachments : \ud30c\uc77c \uc5c5\ub85c\ub4dc \/ file upload<\/p>\n\n\n\n<p>\u2714\ufe0f post_media : \uc5c5\ub85c\ub4dc\ud55c \ud30c\uc77c\uc744 \uae00\uc5d0 \ubd99\uc774\uae30 \/  Insert the uploaded file into the post.<\/p>\n\n\n\n<pre class=\"wp-block-code\" style=\"font-size:14px\"><code>-- \uc774\ubbf8\uc9c0 \ud30c\uc77c \uacbd\ub85c \/ Image file path\nCREATE TABLE IF NOT EXISTS media_attachments (\n  id TEXT PRIMARY KEY,\n  username TEXT NOT NULL,\n  url TEXT NOT NULL,\n  type TEXT NOT NULL,\n  created_at INTEGER\n);\n\n-- posts\uc5d0\uc11c \uae00 \ud558\ub098 \uc9c0\uc6b0\uba74 post_media\uc5d0 \uc788\ub294 \uc5f0\uacb0\uc904\ub3c4 \uc790\ub3d9\uc73c\ub85c \uc9c0\uc6cc\uc9d0\n-- media_attachments\uc5d0\uc11c \ud30c\uc77c \ud558\ub098 \uc9c0\uc6b0\uba74 post_media \uc5f0\uacb0\uc904\ub3c4 \uc790\ub3d9 \uc9c0\uc6cc\uc9d0\n-- Deleting a post from `posts` automatically removes the corresponding link in `post_media`.\n-- Deleting a file from `media_attachments` automatically removes the corresponding link in `post_media`.\n\nCREATE TABLE IF NOT EXISTS post_media (\n  post_id TEXT NOT NULL,\n  media_id TEXT NOT NULL,\n  PRIMARY KEY(post_id, media_id),\n  FOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE,\n  FOREIGN KEY(media_id) REFERENCES media_attachments(id) ON DELETE CASCADE\n);\n\n-- \uc778\ub371\uc2a4 \ucd94\uac00 \/ Add index\nCREATE INDEX IF NOT EXISTS idx_media_username ON media_attachments(username);\nCREATE INDEX IF NOT EXISTS idx_media_created ON media_attachments(created_at);\nCREATE INDEX IF NOT EXISTS idx_post_media_media_id ON post_media(media_id);<\/code><\/pre>\n\n\n\n<p>\ud83d\udcc1 \ucf54\ub4dc \uc218\uc815 \ubc0f \ub77c\uc6b0\ud2b8 \ucd94\uac00 \/ Code modifications and route additions<\/p>\n\n\n\n<p>\u2714\ufe0f lib\/db.ts<\/p>\n\n\n\n<pre class=\"wp-block-code\" style=\"font-size:14px\"><code>\/\/ myapp51 - media<br>db.exec(`<br>... ...<br>  CREATE TABLE IF NOT EXISTS media_attachments (<br>    id TEXT PRIMARY KEY,<br>    username TEXT NOT NULL,<br>    url TEXT NOT NULL,<br>    type TEXT NOT NULL, -- image\/jpeg, image\/png<br>    created_at INTEGER<br>  );<br>  CREATE TABLE IF NOT EXISTS post_media (<br>    post_id TEXT NOT NULL,<br>    media_id TEXT NOT NULL,<br>    PRIMARY KEY(post_id, media_id)<br>  );<br>`);<br>db.exec(`<br>... ...<br>  -- image upload<br>  CREATE INDEX IF NOT EXISTS idx_media_username ON media_attachments(username);<br>  CREATE INDEX IF NOT EXISTS idx_media_created ON media_attachments(created_at);<br>  CREATE INDEX IF NOT EXISTS idx_post_media_media_id ON post_media(media_id);<br>`);<br><br><\/code><\/pre>\n\n\n\n<p>\u2714\ufe0f app\/api\/v1\/media\/route.ts &#8211; \uc774\ubbf8\uc9c0 \uc5c5\ub85c\ub4dc API \uc2e0\uaddc \ucd94\uac00 \/ Added new image upload API.<\/p>\n\n\n\n<pre class=\"wp-block-code\" style=\"font-size:14px\"><code>\/\/ app\/api\/v1\/media\/route.ts\n\/\/  - \u2705 myapp51\nimport db from '@\/lib\/db';\nimport { randomUUID } from 'crypto';\nimport { NextResponse } from 'next\/server';\nimport fs from 'fs';\nimport path from 'path';\n\n\/\/ public\/uploads\nconst UPLOAD_DIR = path.join(process.cwd(), 'public', 'uploads');\n\nfunction getUser(req: Request) {\n  const auth = req.headers.get('Authorization')?.replace('Bearer ','').trim();\n  if (auth) {\n    const o = db.prepare('SELECT username FROM oauth_tokens WHERE access_token=?').get(auth) as any;\n    if (o?.username) return o.username;\n  }\n  \/\/ \ucfe0\ud0a4 \/ Cookie\n  const cookie = req.headers.get('cookie') || '';\n  const m = cookie.match(\/refresh_token=(&#91;^;]+)\/);\n  if (m) {\n    const s = db.prepare('SELECT username FROM sessions WHERE refresh_token=?').get(m&#91;1]) as any;\n    if (s?.username) return s.username;\n  }\n  return null;\n}\n\nexport async function POST(req: Request) {\n  const username = getUser(req);\n  if (!username) return NextResponse.json({error:'unauthorized'}, {status:401});\n\n  const form = await req.formData();\n  const file = form.get('file') as File;\n  if (!file) return NextResponse.json({error:'file required'}, {status:400});\n  if (file.size > 8 * 1024 * 1024) return NextResponse.json({error:'8MB \uc774\ud558\ub9cc'}, {status:400});\n\n  if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, {recursive:true});\n\n  const id = randomUUID();\n  const ext = file.name.split('.').pop() || 'jpg';\n  const filename = `${id}.${ext}`;\n  const buffer = Buffer.from(await file.arrayBuffer());\n  fs.writeFileSync(path.join(UPLOAD_DIR, filename), buffer);\n\n  const url = `\/uploads\/${filename}`;\n  const fullUrl = `https:\/\/${process.env.DOMAIN || 'aloy-horizon.duckdns.org'}${url}`;\n\n  db.prepare('INSERT INTO media_attachments (id, username, url, type, created_at) VALUES (?,?,?,?,?)')\n   .run(id, username, fullUrl, file.type, Date.now());\n\n  \/\/ Mastodon \ud638\ud658 \uc751\ub2f5 \/ Mastodon-compatible response\n  return NextResponse.json({ id, url: fullUrl, preview_url: fullUrl, type: 'image' });\n}<\/code><\/pre>\n\n\n\n<p>\u2714\ufe0f app\/api\/v1\/statuses\/route.ts &#8211; \uae00\uc4f0\uae30 API \uc218\uc815 \/ Modify Writing API<\/p>\n\n\n\n<pre class=\"wp-block-code\" style=\"font-size:14px\"><code>... ...\nexport async function POST(req: Request) {\n... ...\n    \/\/ \u2705 myapp51 \n    \/\/ -  image upload \n    \/\/ - 3\uac00\uc9c0 \ucf00\uc774\uc2a4 \ubaa8\ub450 \uc9c0\uc6d0(\ub9c8\uc2a4\ud1a0\ub3c8,\ub85c\uceec,\uc548\ub4dc\ub85c\uc774\ub4dc\uc571) + \ud06c\ub798\uc2dc \ubc29\uc9c0\n    \/\/ - Supports all three scenarios (Mastodon, local, Android app) + crash prevention.\n    const raw_ids = body.media_ids || body.media?.ids || body.mediaIds || &#91;];\n    const media_ids: string&#91;] = Array.isArray(raw_ids) ? raw_ids : &#91;];\n... ...\n    \/\/ \u2705 myapp51 - image upload , \uc18c\uc720\uad8c \uccb4\ud06c \ud6c4 \uc5f0\uacb0 \/ Connect after verifying ownership\n    \/\/ &#91;'a','b'] \u2192 ... \u2192 'a', 'b'\n    let attachments: any&#91;] = &#91;];\n    if (media_ids.length > 0) {\n      const placeholders = media_ids.map(() => '?').join(',');\n      attachments = db.prepare(\n        `SELECT * FROM media_attachments WHERE id IN (${placeholders}) AND username=?`\n      ).all(...media_ids, username) as any&#91;];\n\n      for (const m of attachments) {\n        try {\n          db.prepare('INSERT INTO post_media (post_id, media_id) VALUES (?,?)').run(id, m.id);\n        } catch (e) {\n          console.error('post_media insert fail', e);\n        }\n      }\n    }\n    \/\/ -  url \uc808\ub300\uacbd\ub85c \ubcf4\uc815 \/ Adjusting absolute URL paths\n    const toAbsoluteUrl = (u: string) => u.startsWith('http') ? u : `https:\/\/${DOMAIN}${u}`;\n... ...\n    \/\/ \u2705 myapp48 + myapp51\n    const note = {\n      id: `https:\/\/${DOMAIN}\/users\/${username}\/statuses\/${id}`,\n      type: 'Note',\n      content: `&lt;p>${content}&lt;\/p>`,\n      attributedTo: `https:\/\/${DOMAIN}\/users\/${username}`,\n      published: new Date(now).toISOString(),\n      to,\n      cc,\n      attachment: attachments.map(m => ({ \/\/ \u2705 myapp51 - image uload\n        type: 'Document',\n        mediaType: m.type,\n        url: toAbsoluteUrl(m.url)\n      }))\n    };\n... ...\n    return NextResponse.json({\n      id: id,\n... ...\n      favourites_count: 0,\n      replies_count: 0,\n      \/\/ \u2705 myapp51 - Pinafore\/Tusky \ubbf8\ub9ac\ubcf4\uae30 \/ \n      media_attachments: attachments.map(m => ({\n        id: m.id,\n        type: 'image',\n        url: toAbsoluteUrl(m.url),\n        preview_url: toAbsoluteUrl(m.url)\n      }))\n    }, {\n      headers: { 'Access-Control-Allow-Origin': '*' }\n    });\n\n\n}<\/code><\/pre>\n\n\n\n<p>\u2714\ufe0f app\/usersui\/[username]\/_components\/themes\/pinafore\/PinaforeTheme.tsx <\/p>\n\n\n\n<p>&#8212; UI Theme\uc218\uc815 \/ Modify UI Theme<\/p>\n\n\n\n<pre class=\"wp-block-code\" style=\"font-size:14px\"><code>\/\/ \u2705 myapp51 - \ud1a0\ud070\uac00\uc838\uc624\uac00 , url \uacbd\ub85c\ubcf4\uc815 \/ Retrieve token, adjust URL path\nconst getToken = () => localStorage.getItem('access_token') || localStorage.getItem('token') || '';\nconst toAbsolute = (u: string) => {\n  if (!u) return '';\n  return u.startsWith('http')? u : `https:\/\/${DOMAIN}${u}`;\n};\n\nexport function PinaforeTheme({ timeline : initialTimeline, username, initialView, onBoost, onLike, onShare }: any) {\n... ...\n  \/\/ \u2705 myapp51 - image upload\n  const &#91;mediaIds, setMediaIds] = useState&lt;string&#91;]>(&#91;]);\n  const &#91;previews, setPreviews] = useState&lt;string&#91;]>(&#91;]);\n  const &#91;uploading, setUploading] = useState(false);\n\n... ...\n\n\/\/ \u2705 myapp48 + myapp51 : write post + image\nconst handlePost = async () => {\n... ...\n      body: JSON.stringify({ \n        status: composeText, \n        visibility: composeVis, \n        media_ids: mediaIds \/\/ \u2705 myapp51 - add media_ids\n      })\n... ...\n    if (res.ok) {\n      const newPost = await res.json();\n... ...\n        media_attachments: newPost.media_attachments || &#91;] \/\/ \u2705 myap51 - preview\n\n    }\n... ...\n};\n}\n\n\/\/ \u2705 myapp51 : image upload\n async function onFiles(e: React.ChangeEvent&lt;HTMLInputElement>) {\n    const files = Array.from(e.target.files || &#91;]);\n    if (files.length === 0) return;\n    if (mediaIds.length + files.length > 4) return alert('\uc774\ubbf8\uc9c0\ub294 \ucd5c\ub300 4\uac1c\uae4c\uc9c0 \/ Up to 4 images.');\n\n    setUploading(true);\n    const token = getToken();\n    try {\n      for(const file of files) {\n        const fd = new FormData();\n        fd.append('file', file);\n        const res = await fetch('\/api\/v1\/media', {\n          method: 'POST',\n          body: fd,\n          headers: {...(token? { Authorization: `Bearer ${token}` } : {}) },\n          credentials: 'include'\n        });\n        if (!res.ok) {\n          const err = await res.json().catch(()=>({}));\n          throw new Error(err.error || 'upload fail');\n        }\n        const data = await res.json();\n        \/\/ data = { id, url, preview_url... }\n        setMediaIds(prev => &#91;...prev, data.id]);\n        setPreviews(prev => &#91;...prev, toAbsolute(data.preview_url || data.url)]);\n      }\n    } catch (err: any) {\n      console.error(err);\n      alert(`\uc5c5\ub85c\ub4dc \uc2e4\ud328\/upload failed: ${err.message}`);\n    } finally {\n      setUploading(false);\n      \/\/ \uac19\uc740 \ud30c\uc77c \ub2e4\uc2dc \uc120\ud0dd \uac00\ub2a5\ud558\ub3c4\ub85d reset\n      e.target.value = '';\n    }\n  }\n\n  return (\n    &lt;>\n      &lt;style>{`\n... ...\n\n       \/* \u2705 myapp51 - composer-choice + preview *\/\n       .composer-choice { display: flex; gap: 8px; align-items: center; margin-top: 10px; }\n       .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; }\n       .composer-choice label:hover { background: #f3f4ff; border-color: #6364ff; }\n       .composer-previews { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 10px; }\n       .preview-item { position: relative; border-radius: 10px; overflow: hidden; border: 1px solid #e6ecf0; background: #f9fafb; }\n       .preview-item img { width: 100%; height: 90px; object-fit: cover; display: block; }\n       .preview-remove { position: absolute; top: 4px;\n... ... \n `}&lt;\/style>\n\n          {isMe &amp;&amp; (\n            &lt;div className=\"pinafore-composer\">\n              &lt;textarea\n                value={composeText}\n                onChange={(e) => setComposeText(e.target.value)}\n                placeholder=\"\ubb34\uc2a8 \uc77c\uc774 \uc77c\uc5b4\ub098\uace0 \uc788\ub098\uc694?\"\n                maxLength={500}\n              \/>\n\n              <strong>{\/* \u2705 myapp51 - \uc774\ubbf8\uc9c0 \uc120\ud0dd \ubc84\ud2bc \/ Image selection button*\/}<\/strong>\n              &lt;div className=\"composer-choice\">\n                  &lt;label style={{cursor:'pointer', fontSize:18, padding:'2px 6px', border:'1px solid #e6ecf0', borderRadius:6, background:'white'}}>\n                    \ud83d\uddbc\ufe0f\n                    &lt;input type=\"file\" multiple accept=\"image\/*\" onChange={onFiles} style={{display:'none'}} \/>\n                  &lt;\/label>\n              &lt;\/div>\n\n             <strong> {\/* \u2705 myapp51 - image preview  *\/}<\/strong>\n              {previews.length > 0 &amp;&amp; (\n                &lt;div className=\"composer-previews\">\n                  {previews.map((src, idx) => (\n                    &lt;div key={idx} className=\"preview-item\">\n                      &lt;img src={src} alt=\"preview\" \/>\n                      &lt;button className=\"preview-remove\" onClick={() => removePreview(idx)}>\u00d7&lt;\/button>\n                    &lt;\/div>\n                  ))}\n                &lt;\/div>\n              )}\n              {uploading &amp;&amp; &lt;div style={{fontSize:12, color:'#6364ff', marginTop:6}}>\ud83d\udce4 \uc5c5\ub85c\ub4dc \uc911 \/ uploading...&lt;\/div>}\n\n\n              &lt;div className=\"composer-foot\">\n... ...\n              &lt;\/div>{\/* \/div className=compose-foot *\/}\n            &lt;\/div>\n          )}\n... ...\n\n{timeline.map((p: any) => (\n... ...\n      &lt;div className=\"status-text\" dangerouslySetInnerHTML={{ __html: p.content }} \/>\n\n       <strong>{\/* \u2705 myapp51 - \ud0c0\uc784\ub77c\uc778 \uc774\ubbf8\uc9c0 \ub80c\ub354 \/ Timeline image render*\/}<\/strong>\n       {p.media_attachments &amp;&amp; p.media_attachments.length > 0 &amp;&amp; (\n        &lt;div className={`status-media ${p.media_attachments.length === 1? 'single' : ''}`}>\n           {p.media_attachments.map((m: any) => (\n            &lt;img key={m.id} src={toAbsolute(m.preview_url || m.url)} alt=\"media\" loading=\"lazy\" \/>\n             ))}\n         &lt;\/div>\n        )}\n... ...\n))}\n);<\/code><\/pre>\n\n\n\n<p>\u2714\ufe0f app\/api\/v1\/statuses\/[id]\/route.ts DELETE <\/p>\n\n\n\n<p>&#8212; \uae00 \uc0ad\uc81c \ubc0f \uc774\ubbf8\uc9c0 \uc0ad\uc81c( \ub2e4\uc74c \ud504\ub85c\uc81d\ud2b8)<br>Delete Post and Delete Image (Next Project)<\/p>\n\n\n\n<p>\ud83d\udcc1 \ud14c\uc2a4\ud2b8 \/ Test<\/p>\n\n\n\n<p>\u2714\ufe0f \uc774\ubbf8\uc9c0 \uc120\ud0dd \/ Select image<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"550\" src=\"https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myap51-image-choice-jpg.jpg\" alt=\"\" class=\"wp-image-11285\" srcset=\"https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myap51-image-choice-jpg.jpg 800w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myap51-image-choice-jpg-300x206.jpg 300w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myap51-image-choice-jpg-768x528.jpg 768w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myap51-image-choice-jpg-400x275.jpg 400w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">\uc774\ubbf8\uc9c0\uc120\ud0dd \/ select image<\/figcaption><\/figure>\n\n\n\n<p>\u2714\ufe0f \uc774\ubbf8\uc9c0 \uc5c5\ub85c\ub4dc &#8211; \ub85c\uceec\uc11c\ubc84 \/ image upload &#8211; local server<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"623\" src=\"https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-jpg.jpg\" alt=\"\" class=\"wp-image-11287\" srcset=\"https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-jpg.jpg 800w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-jpg-300x234.jpg 300w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-jpg-768x598.jpg 768w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-jpg-400x312.jpg 400w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">\ub85c\uceec\uc11c\ubc84 \/ Local Server<\/figcaption><\/figure>\n\n\n\n<p>\u2714\ufe0f \uc774\ubbf8\uc9c0 \uc5c5\ub85c\ub4dc &#8211; \ub9ac\ubaa8\ud2b8\uc11c\ubc84(\uace0\ud22c\uc18c\uc15c) \/ image upload &#8211; remote server(gotosocial)<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"903\" src=\"https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-remote-jpg.jpg\" alt=\"\" class=\"wp-image-11288\" srcset=\"https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-remote-jpg.jpg 800w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-remote-jpg-266x300.jpg 266w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-remote-jpg-768x867.jpg 768w, https:\/\/www.freelifemakers.org\/wordpress\/wp-content\/uploads\/2026\/09\/myapp51-image-posting-remote-jpg-400x452.jpg 400w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">Screenshot<\/figcaption><\/figure>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>\uc694\ud55c\ubcf5\uc74c 8\uc7a5 32\uc808 \/ John 8:32<\/p>\n\n\n\n<p>&#8220;\uadf8\ub9ac\uace0 \ub108\ud76c\ub294 \uc9c4\ub9ac\ub97c \uc54c\uac8c \ub420 \uac83\uc774\uba70, \uc9c4\ub9ac\uac00 \ub108\ud76c\ub97c \uc790\uc720\ub86d\uac8c \ud560 \uac83\uc774\ub2e4.&#8221;<\/p>\n\n\n\n<p>&#8220;Then you will know the truth ,and the truth will set you free&#8221;<\/p>\n<\/blockquote>\n","protected":false},"excerpt":{"rendered":"<p>\ud83d\udc49\ud83c\udffb myapp51\uc5d0\uc11c\ub294 \uae00 \uc791\uc131\uc2dc \uc774\ubbf8\uc9c0 \uc5c5\ub85c\ub4dc \ubd80\ubd84\uc744 \uad6c\ud604\ud569\ub2c8\ub2e4.In myapp51, we are implementing the image upload feature for creating posts. \ud83d\udc49\ud83c\udffb \uc5c5\ub85c\ub4dc\ub41c \uc774\ubbf8\uc9c0 \uacbd\ub85c\ub294 \ud504\ub85c\uc81d\ud2b8 \ub8e8\ud2b8\/public\/uploads\/ \ub514\ub809\ud1a0\ub9ac \uc785\ub2c8\ub2e4.The path for uploaded images is the project root\/public\/uploads\/ directory. \ud83d\udc49\ud83c\udffb \uc5c5\ub85c\ub4dc\ub41c \uc774\ubbf8\uc9c0\ub294 \ub9ac\ubaa8\ud2b8 \uc11c\ubc84\ub85c \uc804\uc1a1\ud558\uc9c0 \uc54a\uace0 \uc6d0\ubcf8 \uc8fc\uc18c \uc808\ub300\uacbd\ub85c (note)\ubd80\ubd84\ub9cc \uc804\uc1a1\ud569\ub2c8\ub2e4.Uploaded images are not transmitted to the remote [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[28,27,1,7,29],"tags":[],"class_list":["post-11244","post","type-post","status-publish","format-standard","hentry","category-news","category-nextjs","category-uncategorized","category-website","category-webui","missing-thumbnail"],"_links":{"self":[{"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/posts\/11244","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/comments?post=11244"}],"version-history":[{"count":39,"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/posts\/11244\/revisions"}],"predecessor-version":[{"id":11289,"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/posts\/11244\/revisions\/11289"}],"wp:attachment":[{"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/media?parent=11244"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/categories?post=11244"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.freelifemakers.org\/wordpress\/index.php\/wp-json\/wp\/v2\/tags?post=11244"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}