From 89c831cba451475a6c404920bc75e82dc1d3b61f Mon Sep 17 00:00:00 2001 From: imnyang Date: Sat, 23 May 2026 22:59:40 +0900 Subject: [PATCH] feat: add global 'a' keyboard shortcut for navigation and refactor backend routes for improved code structure and authentication logic --- apps/backend/src/routes/post.ts | 818 +++++------------- apps/backend/src/routes/proxy.ts | 2 +- apps/frontend/src/app/add/page.tsx | 314 +++---- apps/frontend/src/app/layout.tsx | 3 +- .../src/components/keyboard-shortcuts.tsx | 31 + 5 files changed, 407 insertions(+), 761 deletions(-) create mode 100644 apps/frontend/src/components/keyboard-shortcuts.tsx diff --git a/apps/backend/src/routes/post.ts b/apps/backend/src/routes/post.ts index e25ed0e..b1ffae9 100644 --- a/apps/backend/src/routes/post.ts +++ b/apps/backend/src/routes/post.ts @@ -11,8 +11,12 @@ import { fetchPixivData } from "@/lib/pixiv"; import { checkExistingPostByUrl } from "@/lib/post"; import { makeS3FileName, s3Client, uploadToS3, writeToS3 } from "@/lib/s3"; +// 중복 요청 방지용 인플라이트 락 const inFlightUploads = new Set(); +// ========================================== +// Type Definitions +// ========================================== type UploadResponse = { success: boolean; message: string; @@ -30,6 +34,33 @@ type ExistsResponse = { documentId: string | null; }; +// ========================================== +// Utility Functions (Pure Helpers) +// ========================================== +function createUploadResponse( + success: boolean, + message: string, + extra?: Omit +): UploadResponse { + return { success, message, ...extra }; +} + +function buildUploadKey(url: string, selected: boolean[]): string { + const match = url.match(/\/status\/(\d+)/); + const tweetId = match?.[1] ?? url; + const selectedIndices = selected + .map((isSelected, index) => (isSelected ? index : -1)) + .filter((index) => index >= 0) + .join(","); + return `${tweetId}:${selectedIndices}`; +} + +function makePixivFileName(authorId: string, illustId: string, mediaUrl: string, index: number): string { + const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`; + const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`; + return `pixiv/${authorId}/${illustId}/${withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_")}`; +} + async function sendDiscordNotification(payload: { title: string; url: string; @@ -60,44 +91,6 @@ async function sendDiscordNotification(payload: { } } -function uploadOk(message: string, extra?: Omit): UploadResponse { - return { - success: true, - message, - ...extra, - }; -} - -function uploadError(message: string, extra?: Omit): UploadResponse { - return { - success: false, - message, - ...extra, - }; -} - -function existsResponse(message: string, exists: boolean, source: ExistsResponse["source"], postId: string | null, documentId: string | null): ExistsResponse { - return { - success: true, - message, - exists, - source, - postId, - documentId, - }; -} - -function buildUploadKey(url: string, selected: boolean[]) { - const match = url.match(/\/status\/(\d+)/); - const tweetId = match?.[1] ?? url; - const selectedIndices = selected - .map((isSelected, index) => (isSelected ? index : -1)) - .filter((index) => index >= 0) - .join(","); - - return `${tweetId}:${selectedIndices}`; -} - async function saveTags(tags: string[]) { await Promise.all( tags.map((tag) => @@ -121,32 +114,16 @@ async function syncTagUsage(previousTags: string[], nextTags: string[]) { const removed = Array.from(previous).filter((tag) => !next.has(tag)); const added = Array.from(next).filter((tag) => !previous.has(tag)); - if (added.length > 0) { - await saveTags(added); - } - + if (added.length > 0) await saveTags(added); if (removed.length > 0) { await Promise.all( removed.map((tag) => - Tag.updateOne( - { name: tag }, - { - $inc: { usageCount: -1 }, - $set: { lastUsedAt: new Date() }, - }, - ), - ), + Tag.updateOne({ name: tag }, { $inc: { usageCount: -1 }, $set: { lastUsedAt: new Date() } }) + ) ); } } -function makePixivFileName(authorId: string, illustId: string, mediaUrl: string, index: number) { - const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`; - const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`; - const safeName = withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_"); - return `pixiv/${authorId}/${illustId}/${safeName || `media_${Date.now()}_${index}`}`; -} - async function uploadAndCreateWithRetry(options: { fileName: string; mediaUrl: string; @@ -156,9 +133,7 @@ async function uploadAndCreateWithRetry(options: { const { fileName, mediaUrl, mediaIndex, createDocument } = options; const existingBefore = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); - if (existingBefore) { - return { ok: true as const, created: false, id: existingBefore._id.toString() }; - } + if (existingBefore) return { ok: true as const, created: false, id: existingBefore._id.toString() }; let lastError: unknown; for (let attempt = 1; attempt <= 2; attempt++) { @@ -168,79 +143,65 @@ async function uploadAndCreateWithRetry(options: { } const existing = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); - if (existing) { - return { ok: true as const, created: false, id: existing._id.toString() }; - } + if (existing) return { ok: true as const, created: false, id: existing._id.toString() }; const doc = await createDocument(); return { ok: true as const, created: true, id: doc._id.toString() }; } catch (error) { lastError = error; const existingAfterError = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); - if (existingAfterError) { - return { ok: true as const, created: false, id: existingAfterError._id.toString() }; - } - - if (attempt < 2) { - console.warn(`[Upload retry] key=${fileName} mediaIndex=${mediaIndex}`); - } + if (existingAfterError) return { ok: true as const, created: false, id: existingAfterError._id.toString() }; + if (attempt < 2) console.warn(`[Upload retry] key=${fileName} mediaIndex=${mediaIndex}`); } } - return { ok: false as const, error: lastError }; } +// ========================================== +// Elysia Application Engine Setup +// ========================================== export default new Elysia({ prefix: "/post" }) - .use( - jwt({ - name: "jwt", - secret: config.auth.jwt_secret, - }), - ) + .use(jwt({ name: "jwt", secret: config.auth.jwt_secret })) .onAfterHandle(({ set }) => { - set.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0"; + set.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; set.headers["Pragma"] = "no-cache"; set.headers["Expires"] = "0"; set.headers["Surrogate-Control"] = "no-store"; }) - .get("/total", async ({ query }) => { - const filterTags = normalizeQueryTags(query.tags); - const filter = filterTags.length > 0 - ? { tags: { $in: filterTags } } - : {}; + .derive(async ({ jwt, cookie: { mizuki } }) => { + return { + getAuthenticatedUser: async () => { + const token = mizuki.value; + if (typeof token !== "string" || !token) return null; - const count = await MediaUpload.countDocuments(filter); - return count; - }, { - query: t.Object({ - tags: t.Optional(t.Union([t.String(), t.Array(t.String())])), - }) + const payload = await jwt.verify(token); + if (!payload || typeof payload !== "object" || !("id" in payload)) return null; + + return await User.findOne({ userId: payload.id }); + } + }; }) - .get("/list", async ({ query, jwt, cookie: { mizuki }, status }) => { - const rawToken = mizuki.value; - if (typeof rawToken !== "string" || rawToken.length === 0) { - return status(401, "Unauthorized"); - } + .get("/total", async ({ query }) => { + const filterTags = normalizeQueryTags(query.tags); + return await MediaUpload.countDocuments(filterTags.length > 0 ? { tags: { $in: filterTags } } : {}); + }, { + query: t.Object({ tags: t.Optional(t.Union([t.String(), t.Array(t.String())])) }) + }) - const payload = await jwt.verify(rawToken); - if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { - return status(401, "Unauthorized"); - } + .get("/list", async ({ query, getAuthenticatedUser, status }) => { + const requester = await getAuthenticatedUser(); + if (!requester) return status(401, "Unauthorized"); const page = query.page; const pageSize = query.size || 10; const filterTags = normalizeQueryTags(query.tags); + const filter = filterTags.length > 0 ? { tags: { $in: filterTags } } : {}; - const filter = filterTags.length > 0 - ? { tags: { $in: filterTags } } - : {}; - - const uploads = await MediaUpload.find(filter) + return await MediaUpload.find(filter) .sort({ createdAt: -1 }) .skip((page - 1) * pageSize) .limit(pageSize); - return uploads; }, { query: t.Object({ page: t.Number({ default: 1, minimum: 1 }), @@ -250,38 +211,29 @@ export default new Elysia({ prefix: "/post" }) }) .get("/tags", async () => { - const tags = await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 }); - return tags; + return await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 }); }) .get("/exists", async ({ query }) => { const result = await checkExistingPostByUrl(query.url); - - return existsResponse( - result.exists ? "이미 저장된 게시물입니다." : "저장된 게시물이 없습니다.", - result.exists, - result.source, - result.postId, - result.documentId, - ); + return { + success: true, + message: result.exists ? "이미 저장된 게시물입니다." : "저장된 게시물이 없습니다.", + exists: result.exists, + source: result.source, + postId: result.postId, + documentId: result.documentId, + }; }, { - query: t.Object({ - url: t.String(), - }), + query: t.Object({ url: t.String() }), }) .get("/detail/:id", async ({ params, status }) => { const post = await MediaUpload.findById(params.id); - if (!post) { - return status(404, "포스트를 찾을 수 없습니다."); - } + if (!post) return status(404, "포스트를 찾을 수 없습니다."); const tweetData = typeof post.tweet === "object" && post.tweet !== null - ? post.tweet as { - text?: string; - title?: string; - description?: string; - } + ? (post.tweet as { text?: string; title?: string; description?: string }) : undefined; return { @@ -290,200 +242,118 @@ export default new Elysia({ prefix: "/post" }) url: post.tweet?.url, author: post.author, tags: Array.isArray(post.tags) ? post.tags : [], - tweet: tweetData ? { - text: tweetData.text ?? tweetData.description, - title: tweetData.title, - } : undefined, + tweet: tweetData ? { text: tweetData.text ?? tweetData.description, title: tweetData.title } : undefined, mediaUrl: post.mediaUrl, mediaIndex: post.mediaIndex, mediaType: post.mediaType, thumbnailUrl: post.thumbnailUrl, }; }, { - params: t.Object({ - id: t.String(), - }) + params: t.Object({ id: t.String() }) }) - .delete("/delete/:id", async ({ params, status, jwt, cookie: { mizuki } }) => { - const rawToken = mizuki.value; - if (typeof rawToken !== "string" || rawToken.length === 0) { - return status(401, "Unauthorized"); - } - - const payload = await jwt.verify(rawToken); - if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { - return status(401, "Unauthorized"); - } - - const requester = await User.findOne({ userId: payload.id }); - if (!requester) { - return status(401, "Unauthorized"); - } - - if (requester.role !== "admin" && requester.role !== "writer") { - return status(403, "Forbidden"); - } + .delete("/delete/:id", async ({ params, status, getAuthenticatedUser }) => { + const requester = await getAuthenticatedUser(); + if (!requester) return status(401, "Unauthorized"); + if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden"); const targetPost = await MediaUpload.findById(params.id); const result = await MediaUpload.deleteOne({ _id: params.id }); - if (result.deletedCount === 1) { - await createAuditLog({ - actor: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, - action: "post.delete", - targetType: "post", - targetId: params.id, - summary: `${requester.username} deleted a post`, - detail: { - id: params.id, - mediaUrl: targetPost?.mediaUrl, - }, - }); - return "삭제되었습니다."; - } else { - return status(404, "포스트를 찾을 수 없습니다."); - } + if (result.deletedCount !== 1) return status(404, "포스트를 찾을 수 없습니다."); + + await createAuditLog({ + actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, + action: "post.delete", + targetType: "post", + targetId: params.id, + summary: `${requester.username} deleted a post`, + detail: { id: params.id, mediaUrl: targetPost?.mediaUrl }, + }); + + return "삭제되었습니다."; }, { - params: t.Object({ - id: t.String(), - }) + params: t.Object({ id: t.String() }) }) - .patch("/edit/:id", async ({ params, body, status, jwt, cookie: { mizuki } }) => { - const rawToken = mizuki.value; - if (typeof rawToken !== "string" || rawToken.length === 0) { - return status(401, "Unauthorized"); - } - - const payload = await jwt.verify(rawToken); - if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { - return status(401, "Unauthorized"); - } - - const requester = await User.findOne({ userId: payload.id }); - if (!requester) { - return status(401, "Unauthorized"); - } - - if (requester.role !== "admin" && requester.role !== "writer") { - return status(403, "Forbidden"); - } + .patch("/edit/:id", async ({ params, body, status, getAuthenticatedUser }) => { + const requester = await getAuthenticatedUser(); + if (!requester) return status(401, "Unauthorized"); + if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden"); const post = await MediaUpload.findById(params.id); - if (!post) { - return status(404, "포스트를 찾을 수 없습니다."); - } + if (!post) return status(404, "포스트를 찾을 수 없습니다."); const nextTags = normalizeTags(body.tag ?? post.tags ?? ["미분류"]); const previousTags = Array.isArray(post.tags) ? post.tags : []; + post.tags = nextTags; - if (body.author !== undefined) { - post.author = body.author.trim() || post.author; - } + if (body.author !== undefined) post.author = body.author.trim() || post.author; await post.save(); await syncTagUsage(previousTags, nextTags); await createAuditLog({ - actor: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, + actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, action: "post.edit", targetType: "post", targetId: params.id, summary: `${requester.username} edited a post`, - detail: { - id: params.id, - author: post.author, - tags: nextTags, - }, + detail: { id: params.id, author: post.author, tags: nextTags }, }); return "수정되었습니다."; }, { - params: t.Object({ - id: t.String(), - }), - body: t.Object({ - tag: t.Optional(t.Array(t.String())), - author: t.Optional(t.String()), - }), + params: t.Object({ id: t.String() }), + body: t.Object({ tag: t.Optional(t.Array(t.String())), author: t.Optional(t.String()) }), }) - .post("/upload", async ({ body, status, jwt, cookie: { mizuki } }) => { - const rawToken = mizuki.value; - if (typeof rawToken !== "string" || rawToken.length === 0) { - return status(401, "Unauthorized"); - } - - const payload = await jwt.verify(rawToken); - if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { - return status(401, "Unauthorized"); - } - - const requester = await User.findOne({ userId: payload.id }); - if (!requester) { - return status(401, "Unauthorized"); - } - - if (requester.role !== "admin" && requester.role !== "writer") { - return status(403, "Forbidden"); - } + .post("/upload", async ({ body, status, getAuthenticatedUser }) => { + const requester = await getAuthenticatedUser(); + if (!requester) return status(401, "Unauthorized"); + if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden"); const existingPost = await checkExistingPostByUrl(body.url); if (existingPost.exists) { - return uploadOk("이미 저장된 게시물입니다.", { + return createUploadResponse(true, "이미 저장된 게시물입니다.", { savedCount: 0, failedCount: 0, ids: existingPost.documentId ? [existingPost.documentId] : [], }); } - if (body.url.startsWith("https://www.pixiv.net/")) { - const requestId = crypto.randomUUID(); - const uploadKey = buildUploadKey(body.url, body.selected); + const isPixiv = body.url.startsWith("https://www.pixiv.net/"); + const isTwitter = /https:\/\/(x|twitter|fxtwitter|fixupx|vxwitter)\.com\//.test(body.url); - if (inFlightUploads.has(uploadKey)) { - console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`); - return status(202, uploadError("이미 처리 중인 업로드입니다.")); - } + if (!isPixiv && !isTwitter) return status(400, createUploadResponse(false, "어...")); - inFlightUploads.add(uploadKey); - console.log(`[Pixiv upload started] requestId=${requestId} key=${uploadKey}`); + const requestId = crypto.randomUUID(); + const uploadKey = buildUploadKey(body.url, body.selected); - try { + if (inFlightUploads.has(uploadKey)) { + console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`); + return status(202, createUploadResponse(false, "이미 처리 중인 업로드입니다.")); + } + + inFlightUploads.add(uploadKey); + console.log(`[Upload started] type=${isPixiv ? "Pixiv" : "Twitter"} requestId=${requestId} key=${uploadKey}`); + + try { + let savedCount = 0; + let failedCount = 0; + const savedIds: string[] = []; + const normalizedTags = normalizeTags(body.tag ?? ["미분류"]); + const hasExplicitSelection = body.selected.length > 0; + + if (isPixiv) { const pixivData = await fetchPixivData(body.url); const mediaUrls: string[] = Array.isArray(pixivData.image_proxy_urls) ? pixivData.image_proxy_urls.filter((url: unknown): url is string => typeof url === "string") : []; - if (mediaUrls.length === 0) { - return status(400, uploadError("No media found in the Pixiv artwork.")); - } + if (mediaUrls.length === 0) return status(400, createUploadResponse(false, "No media found in the Pixiv artwork.")); - const normalizedTags = normalizeTags(body.tag ?? ["미분류"]); - - let savedCount = 0; - let failedCount = 0; - const hasExplicitSelection = body.selected.length > 0; - - const savedIds: string[] = []; for (const [index, mediaUrl] of mediaUrls.entries()) { - const isSelected = hasExplicitSelection - ? body.selected[index] === true - : true; - - if (!isSelected) { - continue; - } + if (hasExplicitSelection && !body.selected[index]) continue; const authorId = String(pixivData.author_id || "unknown"); const illustId = String(pixivData.illust_id || `pixiv_${Date.now()}`); @@ -493,258 +363,109 @@ export default new Elysia({ prefix: "/post" }) fileName, mediaUrl, mediaIndex: index, - createDocument: async () => { - return await MediaUpload.create({ - type: "pixiv", - tweet: { - id: illustId, - url: pixivData.url, - title: pixivData.title, - authorId, - authorName: pixivData.author_name, - createDate: pixivData.create_date, - }, - mediaIndex: index, - mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`, - s3Key: fileName, - tags: normalizedTags, - author: body.author ? body.author : (pixivData.author_name || "unknown"), - uploadedBy: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, - }); - }, + createDocument: () => MediaUpload.create({ + type: "pixiv", + tweet: { + id: illustId, + url: pixivData.url, + title: pixivData.title, + authorId, + authorName: pixivData.author_name, + createDate: pixivData.create_date, + }, + mediaIndex: index, + mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`, + s3Key: fileName, + tags: normalizedTags, + author: body.author ? body.author : (pixivData.author_name || "unknown"), + uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, + }), }); - if (!result.ok) { - failedCount += 1; - console.error(`[Pixiv upload failed] index=${index} url=${mediaUrl} key=${fileName}`, result.error); - continue; - } - - if (result.id) { - savedIds.push(result.id); - } - - if (result.created) { - await saveTags(normalizedTags); - savedCount += 1; - } + if (!result.ok) { failedCount++; continue; } + if (result.id) savedIds.push(result.id); + if (result.created) { await saveTags(normalizedTags); savedCount++; } } - if (savedCount === 0 && failedCount === 0) { - console.warn("No Pixiv media uploaded: selected[] did not include any upload target."); - } - - console.log(`Saved ${savedCount} Pixiv media records to MongoDB. Failed: ${failedCount}`); - if (savedCount > 0) { await sendDiscordNotification({ title: pixivData.title || "Pixiv Artwork", - url: pixivData.url, + url: savedIds[0] ? `${config.base_url}/detail/${savedIds[0]}` : body.url, author: body.author ? body.author : (pixivData.author_name || "unknown"), tags: normalizedTags, imageUrl: mediaUrls[0], }); } - - await createAuditLog({ - actor: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, - action: "post.upload.pixiv", - targetId: pixivData.illust_id, - targetType: "post", - summary: `${requester.username} uploaded Pixiv media`, - detail: { - url: body.url, - savedCount, - failedCount, - }, - }); - console.log(`[Pixiv upload finished] requestId=${requestId} key=${uploadKey}`); - if (savedCount === 0 && failedCount > 0) { - return status(502, uploadError("업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.", { - savedCount, - failedCount, - })); - } - - if (savedCount === 0) { - return status(400, uploadError("선택된 이미지가 없습니다.", { - savedCount, - failedCount, - })); - } - - return uploadOk("업로드가 완료되었습니다.", { - savedCount, - failedCount, - ids: savedIds, - }); - } catch (error) { - console.error(`[Pixiv upload aborted] requestId=${requestId} key=${uploadKey}`, error); - return status(500, uploadError("Failed to fetch Pixiv data")); - } finally { - inFlightUploads.delete(uploadKey); - } - } else if (body.url.startsWith("https://x.com/") || body.url.startsWith("https://twitter.com/") || body.url.startsWith("https://fxtwitter.com/") || body.url.startsWith("https://fixupx.com/") || body.url.startsWith("https://vxwitter.com/")) { - const requestId = crypto.randomUUID(); - const uploadKey = buildUploadKey(body.url, body.selected); - - if (inFlightUploads.has(uploadKey)) { - console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`); - return status(202, uploadError("이미 처리 중인 업로드입니다.")); - } - - inFlightUploads.add(uploadKey); - console.log(`[Upload started] requestId=${requestId} key=${uploadKey}`); - - try { + } else { const tweetData = await fetchTweetData(body.url); - let savedCount = 0; - let failedCount = 0; - const savedIds: string[] = []; - if (!tweetData.tweet) { - const apiCode = tweetData.code ?? "unknown"; - console.warn(`[Upload failed] fxtwitter returned no tweet, code=${apiCode} url=${body.url}`); - return status(404, uploadError(`트윗을 찾을 수 없거나 비공개 계정입니다. (code: ${apiCode})`)); + return status(404, createUploadResponse(false, `트윗을 찾을 수 없거나 비공개 계정입니다. (code: ${tweetData.code ?? "unknown"})`)); } - if (tweetData.tweet) { - const media = tweetData.tweet.media?.all || tweetData.tweet.media?.photos || []; - if (media.length > 0) { - const hasExplicitSelection = body.selected.length > 0; - for (const [index, mediaItem] of media.entries()) { - const isSelected = hasExplicitSelection - ? body.selected[index] === true - : true; + const media = tweetData.tweet.media?.all || tweetData.tweet.media?.photos || []; + if (media.length === 0) return status(400, createUploadResponse(false, "트윗에서 이미지를 찾지 못했습니다.")); - if (!isSelected) { - continue; - } + for (const [index, mediaItem] of media.entries()) { + if (hasExplicitSelection && !body.selected[index]) continue; - const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, mediaItem.url, index); + const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, mediaItem.url, index); + const { media: _media, ...tweetWithoutMedia } = tweetData.tweet; + const mediaType = (mediaItem.type === "video" || mediaItem.type === "gif" || (mediaItem.format && mediaItem.format.includes("video"))) ? "video" : "image"; - const { media: _media, ...tweetWithoutMedia } = tweetData.tweet; - const normalizedTags = normalizeTags(body.tag || ["미분류"]); - const mediaType = (mediaItem.type === "video" || mediaItem.type === "gif" || (mediaItem.format && mediaItem.format.includes("video"))) ? "video" : "image"; - - const result = await uploadAndCreateWithRetry({ - fileName, - mediaUrl: mediaItem.url, - mediaIndex: index, - createDocument: async () => { - return await MediaUpload.create({ - type: "twitter", - tweet: tweetWithoutMedia, - mediaIndex: index, - mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`, - s3Key: fileName, - mediaType, - thumbnailUrl: mediaItem.thumbnail_url, - tags: normalizedTags, - author: body.author ? body.author : tweetData.tweet.author.name, - uploadedBy: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, - }); - }, - }); + const result = await uploadAndCreateWithRetry({ + fileName, + mediaUrl: mediaItem.url, + mediaIndex: index, + createDocument: () => MediaUpload.create({ + type: "twitter", + tweet: tweetWithoutMedia, + mediaIndex: index, + mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`, + s3Key: fileName, + mediaType, + thumbnailUrl: mediaItem.thumbnail_url, + tags: normalizedTags, + author: body.author ? body.author : tweetData.tweet.author.name, + uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, + }), + }); - if (!result.ok) { - failedCount += 1; - console.error(`[Upload failed] index=${index} url=${mediaItem.url} key=${fileName}`, result.error); - continue; - } - - if (result.id) { - savedIds.push(result.id); - } - - if (result.created) { - await saveTags(normalizedTags); - savedCount += 1; - } - } - - if (savedCount === 0 && failedCount === 0) { - console.warn("No media uploaded: selected[] did not include any upload target."); - } - - console.log(`Saved ${savedCount} media records to MongoDB. Failed: ${failedCount}`); - - if (savedCount > 0) { - const firstMedia = media[0]; - await sendDiscordNotification({ - title: tweetData.tweet.text?.substring(0, 100) || "Twitter Post", - url: body.url, - author: body.author ? body.author : tweetData.tweet.author.name, - tags: normalizeTags(body.tag || ["미분류"]), - imageUrl: firstMedia.url, - }); - } - - await createAuditLog({ - actor: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, - action: "post.upload.twitter", - targetId: tweetData.tweet.id, - targetType: "post", - summary: `${requester.username} uploaded Twitter media`, - detail: { - url: body.url, - savedCount, - failedCount, - }, - }); - } else { - return status(400, uploadError("트윗에서 이미지를 찾지 못했습니다.")); - } - } - console.log(`[Upload finished] requestId=${requestId} key=${uploadKey}`); - if (savedCount === 0 && failedCount > 0) { - return status(502, uploadError("업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.", { - savedCount, - failedCount, - })); + if (!result.ok) { failedCount++; continue; } + if (result.id) savedIds.push(result.id); + if (result.created) { await saveTags(normalizedTags); savedCount++; } } - if (savedCount === 0) { - return status(400, uploadError("선택된 이미지가 없습니다.", { - savedCount, - failedCount, - })); + if (savedCount > 0) { + await sendDiscordNotification({ + title: tweetData.tweet.text?.substring(0, 100) || "Twitter Post", + url: savedIds[0] ? `${config.base_url}/detail/${savedIds[0]}` : body.url, + author: body.author ? body.author : tweetData.tweet.author.name, + tags: normalizedTags, + imageUrl: media[0].url, + }); } - - return uploadOk("업로드가 완료되었습니다.", { - savedCount, - failedCount, - ids: savedIds, - }); - } catch (error) { - console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error); - console.error(error); - return status(500, uploadError("Failed to fetch tweet data")); - } finally { - inFlightUploads.delete(uploadKey); } - } else { - return status(400, uploadError("어...")); + + await createAuditLog({ + actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, + action: `post.upload.${isPixiv ? "pixiv" : "twitter"}`, + targetId: requestId, + targetType: "post", + summary: `${requester.username} uploaded ${isPixiv ? "Pixiv" : "Twitter"} media`, + detail: { url: body.url, savedCount, failedCount }, + }); + + if (savedCount === 0) { + return status(failedCount > 0 ? 502 : 400, createUploadResponse(false, failedCount > 0 ? "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요." : "선택된 이미지가 없습니다.", { savedCount, failedCount })); + } + + return createUploadResponse(true, "업로드가 완료되었습니다.", { savedCount, failedCount, ids: savedIds }); + + } catch (error) { + console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error); + return status(500, createUploadResponse(false, `Failed to fetch ${isPixiv ? "Pixiv" : "Twitter"} data`)); + } finally { + inFlightUploads.delete(uploadKey); } }, { body: t.Object({ @@ -755,23 +476,19 @@ export default new Elysia({ prefix: "/post" }) }) }) - .post("/upload/direct", async ({ body, status, request }) => { - const requester = (request as any).requester; + .post("/upload/direct", async ({ body, status, getAuthenticatedUser }) => { + const requester = await getAuthenticatedUser(); if (!requester || (requester.role !== "admin" && requester.role !== "writer")) { - return status(401, uploadError("업로드 권한이 없습니다.")); + return status(401, createUploadResponse(false, "업로드 권한이 없습니다.")); } - const { files, author, tag } = body; - const fileList = Array.isArray(files) ? files : [files]; - - if (fileList.length === 0) { - return status(400, uploadError("업로드할 파일이 없습니다.")); - } + const fileList = Array.isArray(body.files) ? body.files : [body.files]; + if (fileList.length === 0) return status(400, createUploadResponse(false, "업로드할 파일이 없습니다.")); let savedCount = 0; let failedCount = 0; const savedIds: string[] = []; - const normalizedTags = normalizeTags(tag || ["미분류"]); + const normalizedTags = normalizeTags(body.tag || ["미분류"]); for (const [index, file] of fileList.entries()) { try { @@ -788,21 +505,13 @@ export default new Elysia({ prefix: "/post" }) s3Key: fileName, mediaType, tags: normalizedTags, - author: author || requester.username || "익명", - uploadedBy: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, + author: body.author || requester.username || "익명", + uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, }); - if (post) { - savedIds.push(post._id.toString()); - savedCount += 1; - } + if (post) { savedIds.push(post._id.toString()); savedCount++; } } catch (error) { - failedCount += 1; + failedCount++; console.error(`[Direct upload failed] name=${file.name}`, error); } } @@ -810,28 +519,15 @@ export default new Elysia({ prefix: "/post" }) if (savedCount > 0) { await saveTags(normalizedTags); await createAuditLog({ - actor: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, + actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, action: "post.upload.direct", targetType: "post", summary: `${requester.username} uploaded ${savedCount} files directly`, - detail: { - savedCount, - failedCount, - ids: savedIds, - }, + detail: { savedCount, failedCount, ids: savedIds }, }); } - return uploadOk("업로드가 완료되었습니다.", { - savedCount, - failedCount, - ids: savedIds, - }); + return createUploadResponse(true, "업로드가 완료되었습니다.", { savedCount, failedCount, ids: savedIds }); }, { body: t.Object({ files: t.Files(), @@ -840,71 +536,33 @@ export default new Elysia({ prefix: "/post" }) }) }) - .post("/bulk-delete", async ({ body, status, jwt, cookie: { mizuki } }) => { - const rawToken = mizuki.value; - if (typeof rawToken !== "string" || rawToken.length === 0) { - return status(401, "Unauthorized"); - } - - const payload = await jwt.verify(rawToken); - if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { - return status(401, "Unauthorized"); - } - - const requester = await User.findOne({ userId: payload.id }); - if (!requester) { - return status(401, "Unauthorized"); - } - - if (requester.role !== "admin" && requester.role !== "writer") { - return status(403, "Forbidden"); - } - - if (!Array.isArray(body.ids) || body.ids.length === 0) { - return status(400, "삭제할 게시물이 없습니다."); - } + .post("/bulk-delete", async ({ body, status, getAuthenticatedUser }) => { + const requester = await getAuthenticatedUser(); + if (!requester) return status(401, "Unauthorized"); + if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden"); const result = await MediaUpload.deleteMany({ _id: { $in: body.ids } }); await createAuditLog({ - actor: { - userId: requester.userId, - discordId: requester.discordId, - username: requester.username, - role: requester.role, - }, + actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, action: "post.bulkDelete", targetType: "post", summary: `${requester.username} deleted multiple posts`, - detail: { - ids: body.ids, - deletedCount: result.deletedCount, - }, + detail: { ids: body.ids, deletedCount: result.deletedCount }, }); - return { - deletedCount: result.deletedCount, - }; + return { deletedCount: result.deletedCount }; }, { - body: t.Object({ - ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }), - }), + body: t.Object({ ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }) }), }) .get("/random", async ({ status }) => { const count = await MediaUpload.countDocuments(); - console.log(`Total posts count: ${count}`); - if (count === 0) { - return status(404, "포스트를 찾을 수 없습니다."); - } + if (count === 0) return status(404, "포스트를 찾을 수 없습니다."); const randomIndex = Math.floor(Math.random() * count); - console.log(`Random index: ${randomIndex}`); const randomPost = await MediaUpload.findOne().skip(randomIndex); - console.log(`Random post: ${randomPost?._id}`); - if (!randomPost) { - return status(404, "포스트를 찾을 수 없습니다."); - } + if (!randomPost) return status(404, "포스트를 찾을 수 없습니다."); return fetch(randomPost.mediaUrl, { cache: "no-store" }); - }); + }); \ No newline at end of file diff --git a/apps/backend/src/routes/proxy.ts b/apps/backend/src/routes/proxy.ts index 83672de..bd16510 100644 --- a/apps/backend/src/routes/proxy.ts +++ b/apps/backend/src/routes/proxy.ts @@ -24,7 +24,7 @@ export default new Elysia({ prefix: "/proxy" }) const response = await fetch(targetUrl, { headers: { - "User-Agent": "Mozilla/5.0 (compatible; bot/1.0)", + "User-Agent": "Mozilla/5.0 (compatible; akiyama.mizuki.guru/1.0)", "Referer": "https://twitter.com/", }, }); diff --git a/apps/frontend/src/app/add/page.tsx b/apps/frontend/src/app/add/page.tsx index e2c524f..26dc725 100644 --- a/apps/frontend/src/app/add/page.tsx +++ b/apps/frontend/src/app/add/page.tsx @@ -6,6 +6,9 @@ import { useRouter } from "next/navigation"; import Header from "../../components/header"; import { proxyMediaUrl } from "../../lib/media"; +// ========================================== +// Types & Types Guards +// ========================================== type SourceType = "twitter" | "pixiv" | "direct"; type PreviewItem = { @@ -55,54 +58,65 @@ type ExistsApiResponse = { documentId: string | null; }; +// ========================================== +// Helper Functions +// ========================================== function detectSource(url: string): SourceType | null { if (/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//.test(url)) { return "twitter"; } - if (/^https?:\/\/(www\.)?pixiv\.net\//.test(url)) { return "pixiv"; } - return null; } -function splitTags(text: string) { +function splitTags(text: string): string[] { return text .split(/[\n,]/) .map((tag) => tag.trim().replace(/^#/, "")) .filter((tag) => tag.length > 0); } +// ========================================== +// Main Component +// ========================================== export default function AddPage() { const router = useRouter(); + const fileInputRef = useRef(null); + + // Form States const [url, setUrl] = useState(""); const [author, setAuthor] = useState(""); const [tagsText, setTagsText] = useState(""); + const [uploadMode, setUploadMode] = useState<"url" | "direct">("url"); + + // UI & API Response States const [previewItems, setPreviewItems] = useState([]); const [selected, setSelected] = useState([]); const [sourceType, setSourceType] = useState(null); + const [lastFetchedUrl, setLastFetchedUrl] = useState(""); + const [existingDetailId, setExistingDetailId] = useState(null); + + // Global Status States const [loadingPreview, setLoadingPreview] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [lastFetchedUrl, setLastFetchedUrl] = useState(""); + + // Auth States const [viewerRole, setViewerRole] = useState("guest"); const [loadingRole, setLoadingRole] = useState(true); - const [existingDetailId, setExistingDetailId] = useState(null); - const [uploadMode, setUploadMode] = useState<"url" | "direct">("url"); - const fileInputRef = useRef(null); - const selectedCount = useMemo( - () => selected.filter(Boolean).length, - [selected], - ); - const canPreview = url.trim().length > 0 && !loadingPreview; - const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0; + // Memoized Values + const selectedCount = useMemo(() => selected.filter(Boolean).length, [selected]); const tags = useMemo(() => splitTags(tagsText), [tagsText]); const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-"; const canManagePost = viewerRole === "admin" || viewerRole === "writer"; + const canPreview = url.trim().length > 0 && !loadingPreview; + const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0; + // 1. Auth Hook useEffect(() => { let active = true; @@ -111,31 +125,52 @@ export default function AddPage() { const response = await fetch("/api/auth/me", { cache: "no-store" }); if (!response.ok) { if (active) setViewerRole("guest"); + window.location.href = "/api/auth/discord/login"; return; } - const me = (await response.json()) as Me; - if (active) { - setViewerRole(me.role); - } + if (active) setViewerRole(me.role); } catch { - if (active) { - setViewerRole("guest"); - } + if (active) setViewerRole("guest"); } finally { - if (active) { - setLoadingRole(false); - } + if (active) setLoadingRole(false); } } void loadMe(); - - return () => { - active = false; - }; + return () => { active = false; }; }, []); + // 2. Clipboard Initialization + useEffect(() => { + navigator.clipboard.readText() + .then((clipboardText) => { + if (!clipboardText.trim()) return; + setUrl(clipboardText); + resetPreview(); + void fetchPreview(clipboardText); + }) + .catch(() => { + // 클립보드 거부 시 예외 처리 방지 + }); + }, []); + + // 3. Auto-Fetch Preview on URL Change (Debounce) + useEffect(() => { + if (loadingPreview) return; + + const trimmed = url.trim(); + if (!trimmed || trimmed === lastFetchedUrl) return; + if (!detectSource(trimmed)) return; + + const timer = setTimeout(() => { + void fetchPreview(trimmed); + }, 450); + + return () => clearTimeout(timer); + }, [url, lastFetchedUrl, loadingPreview]); + + // Actions function resetPreview() { previewItems.forEach((item) => { if (item.file) URL.revokeObjectURL(item.url); @@ -186,10 +221,7 @@ export default function AddPage() { const response = await fetch(`/api/tweet/fetch?url=${encodeURIComponent(trimmedUrl)}`, { cache: "no-store", }); - - if (!response.ok) { - throw new Error(`트위터 미리보기 요청 실패: ${response.status}`); - } + if (!response.ok) throw new Error(`트위터 미리보기 요청 실패: ${response.status}`); const data = (await response.json()) as TweetApiResponse; const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || []; @@ -200,47 +232,36 @@ export default function AddPage() { })) .filter((item) => item.url.length > 0); - if (items.length === 0) { - throw new Error("이미지를 찾지 못했습니다."); - } + if (items.length === 0) throw new Error("이미지를 찾지 못했습니다."); setPreviewItems(items); setSelected(items.map(() => true)); - if (!author.trim()) { - setAuthor(data.tweet?.author?.name ?? ""); - } + if (!author.trim()) setAuthor(data.tweet?.author?.name ?? ""); setLastFetchedUrl(trimmedUrl); return; } + // Pixiv Source const response = await fetch(`/api/pixiv/fetch?url=${encodeURIComponent(trimmedUrl)}`, { cache: "no-store", }); - - if (!response.ok) { - throw new Error(`픽시브 미리보기 요청 실패: ${response.status}`); - } + if (!response.ok) throw new Error(`픽시브 미리보기 요청 실패: ${response.status}`); const data = (await response.json()) as PixivApiResponse; const items = (data.image_proxy_urls ?? []) .filter((imageUrl): imageUrl is string => typeof imageUrl === "string" && imageUrl.length > 0) .map((imageUrl) => ({ url: imageUrl })); - if (items.length === 0) { - throw new Error("이미지를 찾지 못했습니다."); - } + if (items.length === 0) throw new Error("이미지를 찾지 못했습니다."); setPreviewItems(items); setSelected(items.map(() => true)); - if (!author.trim()) { - setAuthor(data.author_name ?? ""); - } + if (!author.trim()) setAuthor(data.author_name ?? ""); if (!tagsText.trim()) { - const pixivTags = (data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", "); - setTagsText(pixivTags); + setTagsText((data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", ")); } - setLastFetchedUrl(trimmedUrl); + } catch (fetchError) { setPreviewItems([]); setSelected([]); @@ -266,101 +287,50 @@ export default function AddPage() { setSelected((prev) => [...prev, ...newItems.map(() => true)]); } - useEffect(() => { - if (loadingPreview) { - return; - } - - const trimmed = url.trim(); - if (!trimmed || trimmed === lastFetchedUrl) { - return; - } - - if (!detectSource(trimmed)) { - return; - } - - const timer = window.setTimeout(() => { - void fetchPreview(trimmed); - }, 450); - - return () => window.clearTimeout(timer); - }, [lastFetchedUrl, loadingPreview, url]); - async function submit(event: FormEvent) { event.preventDefault(); setError(null); setSuccess(null); - if (!canManagePost) { - setError("writer 또는 admin 권한이 필요합니다."); - return; - } - - if (!sourceType) { - setError("먼저 미리보기를 불러와 주세요."); - return; - } - - if (previewItems.length === 0) { - setError("업로드할 이미지가 없습니다."); - return; - } - - if (selectedCount === 0) { - setError("최소 한 장 이상 선택해 주세요."); - return; - } + if (!canManagePost) return setError("writer 또는 admin 권한이 필요합니다."); + if (!sourceType) return setError("먼저 미리보기를 불러와 주세요."); + if (previewItems.length === 0) return setError("업로드할 이미지가 없습니다."); + if (selectedCount === 0) return setError("최소 한 장 이상 선택해 주세요."); setSubmitting(true); try { - const tags = splitTags(tagsText); + const currentTags = splitTags(tagsText); let response: Response; if (uploadMode === "direct") { const formData = new FormData(); previewItems.forEach((item, index) => { - if (selected[index] && item.file) { - formData.append("files", item.file); - } + if (selected[index] && item.file) formData.append("files", item.file); }); if (author.trim()) formData.append("author", author.trim()); - tags.forEach((t) => formData.append("tag", t)); + currentTags.forEach((t) => formData.append("tag", t)); - response = await fetch("/api/post/upload/direct", { - method: "POST", - body: formData, - }); + response = await fetch("/api/post/upload/direct", { method: "POST", body: formData }); } else { response = await fetch("/api/post/upload", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: url.trim(), author: author.trim() || undefined, - tag: tags.length > 0 ? tags : undefined, + tag: currentTags.length > 0 ? currentTags : undefined, selected, }), }); } let data: UploadApiResponse | null = null; - try { - data = (await response.json()) as UploadApiResponse; - } catch { - data = null; - } + try { data = await response.json() as UploadApiResponse; } catch { data = null; } const message = data?.message || `업로드 실패: ${response.status}`; + if (!response.ok || !data?.success) throw new Error(message); - if (!response.ok || !data?.success) { - throw new Error(message); - } - - const uploadedCount = data.savedCount ?? selectedCount; - if (uploadedCount > 0) { + if ((data.savedCount ?? selectedCount) > 0) { setUrl(""); setAuthor(""); setTagsText(""); @@ -368,10 +338,8 @@ export default function AddPage() { } setSuccess(message); + if (data?.ids?.[0]) router.push(`/detail/${data.ids[0]}`); - if (data?.ids && data.ids.length > 0) { - router.push(`/detail/${data.ids[0]}`); - } } catch (submitError) { setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다."); } finally { @@ -384,6 +352,7 @@ export default function AddPage() {
+ {/* Top Info Section */}
@@ -401,56 +370,40 @@ export default function AddPage() {
- {!loadingRole && !canManagePost ? ( + {!loadingRole && !canManagePost && (
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
- ) : null} + )} + {/* Navigation Tabs */}
- - + {(["url", "direct"] as const).map((mode) => ( + + ))}
+ {/* Input Area Group */} {uploadMode === "url" ? (
-
- { - setUrl(event.target.value); - - if (previewItems.length > 0 || sourceType) { - resetPreview(); - } - }} - placeholder="https://x.com/... or https://www.pixiv.net/artworks/..." - className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40" - disabled={loadingPreview} - required={uploadMode === "url"} - /> -
+ { setUrl(e.target.value); resetPreview(); }} + placeholder="https://x.com/... or https://www.pixiv.net/artworks/..." + className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40" + disabled={loadingPreview} + required + />

URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.

) : ( @@ -479,6 +432,7 @@ export default function AddPage() {
)} + {/* Metadata Inputs */}
@@ -486,7 +440,7 @@ export default function AddPage() { id="author" type="text" value={author} - onChange={(event) => setAuthor(event.target.value)} + onChange={(e) => setAuthor(e.target.value)} className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40" />

* 자동으로 작가를 가져옵니다. 만약 잘못된 이름이라면 수동으로 수정할 수 있습니다.

@@ -497,29 +451,31 @@ export default function AddPage() { id="tags" type="text" value={tagsText} - onChange={(event) => setTagsText(event.target.value)} - placeholder="" + onChange={(e) => setTagsText(e.target.value)} className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40" /> -

* 자동으로 태그를 가져옵니다. 쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.

+

* 자동으로 태그를 가져옵니다. 쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.

- {error ?

{error}

: null} - {success ?

{success} {existingDetailId ? ( - - 상세 페이지로 이동 - - ) : null}

: null} + {/* Response Messages */} + {error &&

{error}

} + {success && ( +

+ {success}{" "} + {existingDetailId && ( + + 상세 페이지로 이동 + + )} +

+ )} - {loadingPreview ? ( + {loadingPreview && (
미리보기를 불러오는 중입니다. 이미지 개수에 따라 몇 초 정도 소요될 수 있습니다.
- ) : null} + )}
source: {sourceLabel} @@ -527,7 +483,8 @@ export default function AddPage() { tags: {tags.length}
- {previewItems.length > 0 ? ( + {/* Preview Asset Grid */} + {previewItems.length > 0 && (

Preview

@@ -583,11 +540,11 @@ export default function AddPage() { }} />
- {selected[index] ? ( + {selected[index] && (
selected
- ) : null} + )}
#{index + 1}
@@ -595,8 +552,9 @@ export default function AddPage() { ))}
- ) : null} + )} + {/* Submit Action */}
- -
); -} +} \ No newline at end of file diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index a7f0706..ed04a11 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import "./globals.css"; import "react-photo-album/masonry.css"; - import { ThemeProvider } from "../components/theme-provider"; +import KeyboardShortcuts from "../components/keyboard-shortcuts"; export const metadata: Metadata = { title: "Akiyama Mizuki", @@ -23,6 +23,7 @@ export default function RootLayout({ > + {children} diff --git a/apps/frontend/src/components/keyboard-shortcuts.tsx b/apps/frontend/src/components/keyboard-shortcuts.tsx new file mode 100644 index 0000000..2b89689 --- /dev/null +++ b/apps/frontend/src/components/keyboard-shortcuts.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function KeyboardShortcuts() { + const router = useRouter(); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + // input, textarea, contenteditable에서는 무시 + const target = event.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + if (event.key === "a" || event.key === "A") { + router.push("/add"); + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [router]); + + return null; +}