feat: add global 'a' keyboard shortcut for navigation and refactor backend routes for improved code structure and authentication logic

This commit is contained in:
암냥 2026-05-23 22:59:40 +09:00
commit 89c831cba4
No known key found for this signature in database
5 changed files with 405 additions and 759 deletions

View file

@ -11,8 +11,12 @@ import { fetchPixivData } from "@/lib/pixiv";
import { checkExistingPostByUrl } from "@/lib/post"; import { checkExistingPostByUrl } from "@/lib/post";
import { makeS3FileName, s3Client, uploadToS3, writeToS3 } from "@/lib/s3"; import { makeS3FileName, s3Client, uploadToS3, writeToS3 } from "@/lib/s3";
// 중복 요청 방지용 인플라이트 락
const inFlightUploads = new Set<string>(); const inFlightUploads = new Set<string>();
// ==========================================
// Type Definitions
// ==========================================
type UploadResponse = { type UploadResponse = {
success: boolean; success: boolean;
message: string; message: string;
@ -30,6 +34,33 @@ type ExistsResponse = {
documentId: string | null; documentId: string | null;
}; };
// ==========================================
// Utility Functions (Pure Helpers)
// ==========================================
function createUploadResponse(
success: boolean,
message: string,
extra?: Omit<UploadResponse, "success" | "message">
): 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: { async function sendDiscordNotification(payload: {
title: string; title: string;
url: string; url: string;
@ -60,44 +91,6 @@ async function sendDiscordNotification(payload: {
} }
} }
function uploadOk(message: string, extra?: Omit<UploadResponse, "success" | "message">): UploadResponse {
return {
success: true,
message,
...extra,
};
}
function uploadError(message: string, extra?: Omit<UploadResponse, "success" | "message">): 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[]) { async function saveTags(tags: string[]) {
await Promise.all( await Promise.all(
tags.map((tag) => 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 removed = Array.from(previous).filter((tag) => !next.has(tag));
const added = Array.from(next).filter((tag) => !previous.has(tag)); const added = Array.from(next).filter((tag) => !previous.has(tag));
if (added.length > 0) { if (added.length > 0) await saveTags(added);
await saveTags(added);
}
if (removed.length > 0) { if (removed.length > 0) {
await Promise.all( await Promise.all(
removed.map((tag) => removed.map((tag) =>
Tag.updateOne( Tag.updateOne({ name: tag }, { $inc: { usageCount: -1 }, $set: { lastUsedAt: new Date() } })
{ 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: { async function uploadAndCreateWithRetry(options: {
fileName: string; fileName: string;
mediaUrl: string; mediaUrl: string;
@ -156,9 +133,7 @@ async function uploadAndCreateWithRetry(options: {
const { fileName, mediaUrl, mediaIndex, createDocument } = options; const { fileName, mediaUrl, mediaIndex, createDocument } = options;
const existingBefore = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); const existingBefore = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
if (existingBefore) { if (existingBefore) return { ok: true as const, created: false, id: existingBefore._id.toString() };
return { ok: true as const, created: false, id: existingBefore._id.toString() };
}
let lastError: unknown; let lastError: unknown;
for (let attempt = 1; attempt <= 2; attempt++) { for (let attempt = 1; attempt <= 2; attempt++) {
@ -168,79 +143,65 @@ async function uploadAndCreateWithRetry(options: {
} }
const existing = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); const existing = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
if (existing) { if (existing) return { ok: true as const, created: false, id: existing._id.toString() };
return { ok: true as const, created: false, id: existing._id.toString() };
}
const doc = await createDocument(); const doc = await createDocument();
return { ok: true as const, created: true, id: doc._id.toString() }; return { ok: true as const, created: true, id: doc._id.toString() };
} catch (error) { } catch (error) {
lastError = error; lastError = error;
const existingAfterError = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); const existingAfterError = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
if (existingAfterError) { if (existingAfterError) return { ok: true as const, created: false, id: existingAfterError._id.toString() };
return { ok: true as const, created: false, id: existingAfterError._id.toString() }; if (attempt < 2) console.warn(`[Upload retry] key=${fileName} mediaIndex=${mediaIndex}`);
}
if (attempt < 2) {
console.warn(`[Upload retry] key=${fileName} mediaIndex=${mediaIndex}`);
} }
} }
}
return { ok: false as const, error: lastError }; return { ok: false as const, error: lastError };
} }
// ==========================================
// Elysia Application Engine Setup
// ==========================================
export default new Elysia({ prefix: "/post" }) export default new Elysia({ prefix: "/post" })
.use( .use(jwt({ name: "jwt", secret: config.auth.jwt_secret }))
jwt({
name: "jwt",
secret: config.auth.jwt_secret,
}),
)
.onAfterHandle(({ set }) => { .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["Pragma"] = "no-cache";
set.headers["Expires"] = "0"; set.headers["Expires"] = "0";
set.headers["Surrogate-Control"] = "no-store"; set.headers["Surrogate-Control"] = "no-store";
}) })
.derive(async ({ jwt, cookie: { mizuki } }) => {
return {
getAuthenticatedUser: async () => {
const token = mizuki.value;
if (typeof token !== "string" || !token) return null;
const payload = await jwt.verify(token);
if (!payload || typeof payload !== "object" || !("id" in payload)) return null;
return await User.findOne({ userId: payload.id });
}
};
})
.get("/total", async ({ query }) => { .get("/total", async ({ query }) => {
const filterTags = normalizeQueryTags(query.tags); const filterTags = normalizeQueryTags(query.tags);
const filter = filterTags.length > 0 return await MediaUpload.countDocuments(filterTags.length > 0 ? { tags: { $in: filterTags } } : {});
? { tags: { $in: filterTags } }
: {};
const count = await MediaUpload.countDocuments(filter);
return count;
}, { }, {
query: t.Object({ query: t.Object({ tags: t.Optional(t.Union([t.String(), t.Array(t.String())])) })
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
})
}) })
.get("/list", async ({ query, jwt, cookie: { mizuki }, status }) => { .get("/list", async ({ query, getAuthenticatedUser, status }) => {
const rawToken = mizuki.value; const requester = await getAuthenticatedUser();
if (typeof rawToken !== "string" || rawToken.length === 0) { if (!requester) return status(401, "Unauthorized");
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 page = query.page; const page = query.page;
const pageSize = query.size || 10; const pageSize = query.size || 10;
const filterTags = normalizeQueryTags(query.tags); const filterTags = normalizeQueryTags(query.tags);
const filter = filterTags.length > 0 ? { tags: { $in: filterTags } } : {};
const filter = filterTags.length > 0 return await MediaUpload.find(filter)
? { tags: { $in: filterTags } }
: {};
const uploads = await MediaUpload.find(filter)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.skip((page - 1) * pageSize) .skip((page - 1) * pageSize)
.limit(pageSize); .limit(pageSize);
return uploads;
}, { }, {
query: t.Object({ query: t.Object({
page: t.Number({ default: 1, minimum: 1 }), page: t.Number({ default: 1, minimum: 1 }),
@ -250,38 +211,29 @@ export default new Elysia({ prefix: "/post" })
}) })
.get("/tags", async () => { .get("/tags", async () => {
const tags = await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 }); return await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 });
return tags;
}) })
.get("/exists", async ({ query }) => { .get("/exists", async ({ query }) => {
const result = await checkExistingPostByUrl(query.url); const result = await checkExistingPostByUrl(query.url);
return {
return existsResponse( success: true,
result.exists ? "이미 저장된 게시물입니다." : "저장된 게시물이 없습니다.", message: result.exists ? "이미 저장된 게시물입니다." : "저장된 게시물이 없습니다.",
result.exists, exists: result.exists,
result.source, source: result.source,
result.postId, postId: result.postId,
result.documentId, documentId: result.documentId,
); };
}, { }, {
query: t.Object({ query: t.Object({ url: t.String() }),
url: t.String(),
}),
}) })
.get("/detail/:id", async ({ params, status }) => { .get("/detail/:id", async ({ params, status }) => {
const post = await MediaUpload.findById(params.id); const post = await MediaUpload.findById(params.id);
if (!post) { if (!post) return status(404, "포스트를 찾을 수 없습니다.");
return status(404, "포스트를 찾을 수 없습니다.");
}
const tweetData = typeof post.tweet === "object" && post.tweet !== null const tweetData = typeof post.tweet === "object" && post.tweet !== null
? post.tweet as { ? (post.tweet as { text?: string; title?: string; description?: string })
text?: string;
title?: string;
description?: string;
}
: undefined; : undefined;
return { return {
@ -290,200 +242,118 @@ export default new Elysia({ prefix: "/post" })
url: post.tweet?.url, url: post.tweet?.url,
author: post.author, author: post.author,
tags: Array.isArray(post.tags) ? post.tags : [], tags: Array.isArray(post.tags) ? post.tags : [],
tweet: tweetData ? { tweet: tweetData ? { text: tweetData.text ?? tweetData.description, title: tweetData.title } : undefined,
text: tweetData.text ?? tweetData.description,
title: tweetData.title,
} : undefined,
mediaUrl: post.mediaUrl, mediaUrl: post.mediaUrl,
mediaIndex: post.mediaIndex, mediaIndex: post.mediaIndex,
mediaType: post.mediaType, mediaType: post.mediaType,
thumbnailUrl: post.thumbnailUrl, thumbnailUrl: post.thumbnailUrl,
}; };
}, { }, {
params: t.Object({ params: t.Object({ id: t.String() })
id: t.String(),
})
}) })
.delete("/delete/:id", async ({ params, status, jwt, cookie: { mizuki } }) => { .delete("/delete/:id", async ({ params, status, getAuthenticatedUser }) => {
const rawToken = mizuki.value; const requester = await getAuthenticatedUser();
if (typeof rawToken !== "string" || rawToken.length === 0) { if (!requester) return status(401, "Unauthorized");
return status(401, "Unauthorized"); if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden");
}
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");
}
const targetPost = await MediaUpload.findById(params.id); const targetPost = await MediaUpload.findById(params.id);
const result = await MediaUpload.deleteOne({ _id: params.id }); const result = await MediaUpload.deleteOne({ _id: params.id });
if (result.deletedCount === 1) { if (result.deletedCount !== 1) return status(404, "포스트를 찾을 수 없습니다.");
await createAuditLog({ await createAuditLog({
actor: { actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role },
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
action: "post.delete", action: "post.delete",
targetType: "post", targetType: "post",
targetId: params.id, targetId: params.id,
summary: `${requester.username} deleted a post`, summary: `${requester.username} deleted a post`,
detail: { detail: { id: params.id, mediaUrl: targetPost?.mediaUrl },
id: params.id,
mediaUrl: targetPost?.mediaUrl,
},
}); });
return "삭제되었습니다."; return "삭제되었습니다.";
} else {
return status(404, "포스트를 찾을 수 없습니다.");
}
}, { }, {
params: t.Object({ params: t.Object({ id: t.String() })
id: t.String(),
})
}) })
.patch("/edit/:id", async ({ params, body, status, jwt, cookie: { mizuki } }) => { .patch("/edit/:id", async ({ params, body, status, getAuthenticatedUser }) => {
const rawToken = mizuki.value; const requester = await getAuthenticatedUser();
if (typeof rawToken !== "string" || rawToken.length === 0) { if (!requester) return status(401, "Unauthorized");
return status(401, "Unauthorized"); if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden");
}
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");
}
const post = await MediaUpload.findById(params.id); const post = await MediaUpload.findById(params.id);
if (!post) { if (!post) return status(404, "포스트를 찾을 수 없습니다.");
return status(404, "포스트를 찾을 수 없습니다.");
}
const nextTags = normalizeTags(body.tag ?? post.tags ?? ["미분류"]); const nextTags = normalizeTags(body.tag ?? post.tags ?? ["미분류"]);
const previousTags = Array.isArray(post.tags) ? post.tags : []; const previousTags = Array.isArray(post.tags) ? post.tags : [];
post.tags = nextTags; post.tags = nextTags;
if (body.author !== undefined) { if (body.author !== undefined) post.author = body.author.trim() || post.author;
post.author = body.author.trim() || post.author;
}
await post.save(); await post.save();
await syncTagUsage(previousTags, nextTags); await syncTagUsage(previousTags, nextTags);
await createAuditLog({ await createAuditLog({
actor: { actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role },
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
action: "post.edit", action: "post.edit",
targetType: "post", targetType: "post",
targetId: params.id, targetId: params.id,
summary: `${requester.username} edited a post`, summary: `${requester.username} edited a post`,
detail: { detail: { id: params.id, author: post.author, tags: nextTags },
id: params.id,
author: post.author,
tags: nextTags,
},
}); });
return "수정되었습니다."; return "수정되었습니다.";
}, { }, {
params: t.Object({ params: t.Object({ id: t.String() }),
id: t.String(), body: t.Object({ tag: t.Optional(t.Array(t.String())), author: t.Optional(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 } }) => { .post("/upload", async ({ body, status, getAuthenticatedUser }) => {
const rawToken = mizuki.value; const requester = await getAuthenticatedUser();
if (typeof rawToken !== "string" || rawToken.length === 0) { if (!requester) return status(401, "Unauthorized");
return status(401, "Unauthorized"); if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden");
}
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");
}
const existingPost = await checkExistingPostByUrl(body.url); const existingPost = await checkExistingPostByUrl(body.url);
if (existingPost.exists) { if (existingPost.exists) {
return uploadOk("이미 저장된 게시물입니다.", { return createUploadResponse(true, "이미 저장된 게시물입니다.", {
savedCount: 0, savedCount: 0,
failedCount: 0, failedCount: 0,
ids: existingPost.documentId ? [existingPost.documentId] : [], ids: existingPost.documentId ? [existingPost.documentId] : [],
}); });
} }
if (body.url.startsWith("https://www.pixiv.net/")) { const isPixiv = body.url.startsWith("https://www.pixiv.net/");
const isTwitter = /https:\/\/(x|twitter|fxtwitter|fixupx|vxwitter)\.com\//.test(body.url);
if (!isPixiv && !isTwitter) return status(400, createUploadResponse(false, "어..."));
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const uploadKey = buildUploadKey(body.url, body.selected); const uploadKey = buildUploadKey(body.url, body.selected);
if (inFlightUploads.has(uploadKey)) { if (inFlightUploads.has(uploadKey)) {
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`); console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
return status(202, uploadError("이미 처리 중인 업로드입니다.")); return status(202, createUploadResponse(false, "이미 처리 중인 업로드입니다."));
} }
inFlightUploads.add(uploadKey); inFlightUploads.add(uploadKey);
console.log(`[Pixiv upload started] requestId=${requestId} key=${uploadKey}`); console.log(`[Upload started] type=${isPixiv ? "Pixiv" : "Twitter"} requestId=${requestId} key=${uploadKey}`);
try { 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 pixivData = await fetchPixivData(body.url);
const mediaUrls: string[] = Array.isArray(pixivData.image_proxy_urls) const mediaUrls: string[] = Array.isArray(pixivData.image_proxy_urls)
? pixivData.image_proxy_urls.filter((url: unknown): url is string => typeof url === "string") ? pixivData.image_proxy_urls.filter((url: unknown): url is string => typeof url === "string")
: []; : [];
if (mediaUrls.length === 0) { if (mediaUrls.length === 0) return status(400, createUploadResponse(false, "No media found in the Pixiv artwork."));
return status(400, uploadError("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()) { for (const [index, mediaUrl] of mediaUrls.entries()) {
const isSelected = hasExplicitSelection if (hasExplicitSelection && !body.selected[index]) continue;
? body.selected[index] === true
: true;
if (!isSelected) {
continue;
}
const authorId = String(pixivData.author_id || "unknown"); const authorId = String(pixivData.author_id || "unknown");
const illustId = String(pixivData.illust_id || `pixiv_${Date.now()}`); const illustId = String(pixivData.illust_id || `pixiv_${Date.now()}`);
@ -493,8 +363,7 @@ export default new Elysia({ prefix: "/post" })
fileName, fileName,
mediaUrl, mediaUrl,
mediaIndex: index, mediaIndex: index,
createDocument: async () => { createDocument: () => MediaUpload.create({
return await MediaUpload.create({
type: "pixiv", type: "pixiv",
tweet: { tweet: {
id: illustId, id: illustId,
@ -509,140 +378,45 @@ export default new Elysia({ prefix: "/post" })
s3Key: fileName, s3Key: fileName,
tags: normalizedTags, tags: normalizedTags,
author: body.author ? body.author : (pixivData.author_name || "unknown"), author: body.author ? body.author : (pixivData.author_name || "unknown"),
uploadedBy: { uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role },
userId: requester.userId, }),
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
});
},
}); });
if (!result.ok) { if (!result.ok) { failedCount++; continue; }
failedCount += 1; if (result.id) savedIds.push(result.id);
console.error(`[Pixiv upload failed] index=${index} url=${mediaUrl} key=${fileName}`, result.error); if (result.created) { await saveTags(normalizedTags); savedCount++; }
continue;
} }
if (result.id) {
savedIds.push(result.id);
}
if (result.created) {
await saveTags(normalizedTags);
savedCount += 1;
}
}
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) { if (savedCount > 0) {
await sendDiscordNotification({ await sendDiscordNotification({
title: pixivData.title || "Pixiv Artwork", 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"), author: body.author ? body.author : (pixivData.author_name || "unknown"),
tags: normalizedTags, tags: normalizedTags,
imageUrl: mediaUrls[0], imageUrl: mediaUrls[0],
}); });
} }
} else {
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 {
const tweetData = await fetchTweetData(body.url); const tweetData = await fetchTweetData(body.url);
let savedCount = 0;
let failedCount = 0;
const savedIds: string[] = [];
if (!tweetData.tweet) { if (!tweetData.tweet) {
const apiCode = tweetData.code ?? "unknown"; return status(404, createUploadResponse(false, `트윗을 찾을 수 없거나 비공개 계정입니다. (code: ${tweetData.code ?? "unknown"})`));
console.warn(`[Upload failed] fxtwitter returned no tweet, code=${apiCode} url=${body.url}`);
return status(404, uploadError(`트윗을 찾을 수 없거나 비공개 계정입니다. (code: ${apiCode})`));
} }
if (tweetData.tweet) {
const media = tweetData.tweet.media?.all || tweetData.tweet.media?.photos || []; const media = tweetData.tweet.media?.all || tweetData.tweet.media?.photos || [];
if (media.length > 0) { if (media.length === 0) return status(400, createUploadResponse(false, "트윗에서 이미지를 찾지 못했습니다."));
const hasExplicitSelection = body.selected.length > 0;
for (const [index, mediaItem] of media.entries()) {
const isSelected = hasExplicitSelection
? body.selected[index] === true
: true;
if (!isSelected) { for (const [index, mediaItem] of media.entries()) {
continue; 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 { 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 mediaType = (mediaItem.type === "video" || mediaItem.type === "gif" || (mediaItem.format && mediaItem.format.includes("video"))) ? "video" : "image";
const result = await uploadAndCreateWithRetry({ const result = await uploadAndCreateWithRetry({
fileName, fileName,
mediaUrl: mediaItem.url, mediaUrl: mediaItem.url,
mediaIndex: index, mediaIndex: index,
createDocument: async () => { createDocument: () => MediaUpload.create({
return await MediaUpload.create({
type: "twitter", type: "twitter",
tweet: tweetWithoutMedia, tweet: tweetWithoutMedia,
mediaIndex: index, mediaIndex: index,
@ -652,100 +426,47 @@ export default new Elysia({ prefix: "/post" })
thumbnailUrl: mediaItem.thumbnail_url, thumbnailUrl: mediaItem.thumbnail_url,
tags: normalizedTags, tags: normalizedTags,
author: body.author ? body.author : tweetData.tweet.author.name, author: body.author ? body.author : tweetData.tweet.author.name,
uploadedBy: { uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role },
userId: requester.userId, }),
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
});
},
}); });
if (!result.ok) { if (!result.ok) { failedCount++; continue; }
failedCount += 1; if (result.id) savedIds.push(result.id);
console.error(`[Upload failed] index=${index} url=${mediaItem.url} key=${fileName}`, result.error); if (result.created) { await saveTags(normalizedTags); savedCount++; }
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) { if (savedCount > 0) {
const firstMedia = media[0];
await sendDiscordNotification({ await sendDiscordNotification({
title: tweetData.tweet.text?.substring(0, 100) || "Twitter Post", title: tweetData.tweet.text?.substring(0, 100) || "Twitter Post",
url: body.url, url: savedIds[0] ? `${config.base_url}/detail/${savedIds[0]}` : body.url,
author: body.author ? body.author : tweetData.tweet.author.name, author: body.author ? body.author : tweetData.tweet.author.name,
tags: normalizeTags(body.tag || ["미분류"]), tags: normalizedTags,
imageUrl: firstMedia.url, imageUrl: media[0].url,
}); });
} }
}
await createAuditLog({ await createAuditLog({
actor: { actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role },
userId: requester.userId, action: `post.upload.${isPixiv ? "pixiv" : "twitter"}`,
discordId: requester.discordId, targetId: requestId,
username: requester.username,
role: requester.role,
},
action: "post.upload.twitter",
targetId: tweetData.tweet.id,
targetType: "post", targetType: "post",
summary: `${requester.username} uploaded Twitter media`, summary: `${requester.username} uploaded ${isPixiv ? "Pixiv" : "Twitter"} media`,
detail: { detail: { url: body.url, savedCount, failedCount },
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 (savedCount === 0) { if (savedCount === 0) {
return status(400, uploadError("선택된 이미지가 없습니다.", { return status(failedCount > 0 ? 502 : 400, createUploadResponse(false, failedCount > 0 ? "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요." : "선택된 이미지가 없습니다.", { savedCount, failedCount }));
savedCount,
failedCount,
}));
} }
return uploadOk("업로드가 완료되었습니다.", { return createUploadResponse(true, "업로드가 완료되었습니다.", { savedCount, failedCount, ids: savedIds });
savedCount,
failedCount,
ids: savedIds,
});
} catch (error) { } catch (error) {
console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error); console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error);
console.error(error); return status(500, createUploadResponse(false, `Failed to fetch ${isPixiv ? "Pixiv" : "Twitter"} data`));
return status(500, uploadError("Failed to fetch tweet data"));
} finally { } finally {
inFlightUploads.delete(uploadKey); inFlightUploads.delete(uploadKey);
} }
} else {
return status(400, uploadError("어..."));
}
}, { }, {
body: t.Object({ body: t.Object({
url: t.String(), url: t.String(),
@ -755,23 +476,19 @@ export default new Elysia({ prefix: "/post" })
}) })
}) })
.post("/upload/direct", async ({ body, status, request }) => { .post("/upload/direct", async ({ body, status, getAuthenticatedUser }) => {
const requester = (request as any).requester; const requester = await getAuthenticatedUser();
if (!requester || (requester.role !== "admin" && requester.role !== "writer")) { 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(body.files) ? body.files : [body.files];
const fileList = Array.isArray(files) ? files : [files]; if (fileList.length === 0) return status(400, createUploadResponse(false, "업로드할 파일이 없습니다."));
if (fileList.length === 0) {
return status(400, uploadError("업로드할 파일이 없습니다."));
}
let savedCount = 0; let savedCount = 0;
let failedCount = 0; let failedCount = 0;
const savedIds: string[] = []; const savedIds: string[] = [];
const normalizedTags = normalizeTags(tag || ["미분류"]); const normalizedTags = normalizeTags(body.tag || ["미분류"]);
for (const [index, file] of fileList.entries()) { for (const [index, file] of fileList.entries()) {
try { try {
@ -788,21 +505,13 @@ export default new Elysia({ prefix: "/post" })
s3Key: fileName, s3Key: fileName,
mediaType, mediaType,
tags: normalizedTags, tags: normalizedTags,
author: author || requester.username || "익명", author: body.author || requester.username || "익명",
uploadedBy: { uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role },
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
}); });
if (post) { if (post) { savedIds.push(post._id.toString()); savedCount++; }
savedIds.push(post._id.toString());
savedCount += 1;
}
} catch (error) { } catch (error) {
failedCount += 1; failedCount++;
console.error(`[Direct upload failed] name=${file.name}`, error); console.error(`[Direct upload failed] name=${file.name}`, error);
} }
} }
@ -810,28 +519,15 @@ export default new Elysia({ prefix: "/post" })
if (savedCount > 0) { if (savedCount > 0) {
await saveTags(normalizedTags); await saveTags(normalizedTags);
await createAuditLog({ await createAuditLog({
actor: { actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role },
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
action: "post.upload.direct", action: "post.upload.direct",
targetType: "post", targetType: "post",
summary: `${requester.username} uploaded ${savedCount} files directly`, summary: `${requester.username} uploaded ${savedCount} files directly`,
detail: { detail: { savedCount, failedCount, ids: savedIds },
savedCount,
failedCount,
ids: savedIds,
},
}); });
} }
return uploadOk("업로드가 완료되었습니다.", { return createUploadResponse(true, "업로드가 완료되었습니다.", { savedCount, failedCount, ids: savedIds });
savedCount,
failedCount,
ids: savedIds,
});
}, { }, {
body: t.Object({ body: t.Object({
files: t.Files(), files: t.Files(),
@ -840,71 +536,33 @@ export default new Elysia({ prefix: "/post" })
}) })
}) })
.post("/bulk-delete", async ({ body, status, jwt, cookie: { mizuki } }) => { .post("/bulk-delete", async ({ body, status, getAuthenticatedUser }) => {
const rawToken = mizuki.value; const requester = await getAuthenticatedUser();
if (typeof rawToken !== "string" || rawToken.length === 0) { if (!requester) return status(401, "Unauthorized");
return status(401, "Unauthorized"); if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden");
}
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, "삭제할 게시물이 없습니다.");
}
const result = await MediaUpload.deleteMany({ _id: { $in: body.ids } }); const result = await MediaUpload.deleteMany({ _id: { $in: body.ids } });
await createAuditLog({ await createAuditLog({
actor: { actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role },
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
action: "post.bulkDelete", action: "post.bulkDelete",
targetType: "post", targetType: "post",
summary: `${requester.username} deleted multiple posts`, summary: `${requester.username} deleted multiple posts`,
detail: { detail: { ids: body.ids, deletedCount: result.deletedCount },
ids: body.ids,
deletedCount: result.deletedCount,
},
}); });
return { return { deletedCount: result.deletedCount };
deletedCount: result.deletedCount,
};
}, { }, {
body: t.Object({ body: t.Object({ ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }) }),
ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }),
}),
}) })
.get("/random", async ({ status }) => { .get("/random", async ({ status }) => {
const count = await MediaUpload.countDocuments(); 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); const randomIndex = Math.floor(Math.random() * count);
console.log(`Random index: ${randomIndex}`);
const randomPost = await MediaUpload.findOne().skip(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" }); return fetch(randomPost.mediaUrl, { cache: "no-store" });
}); });

View file

@ -24,7 +24,7 @@ export default new Elysia({ prefix: "/proxy" })
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
headers: { 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/", "Referer": "https://twitter.com/",
}, },
}); });

View file

@ -6,6 +6,9 @@ import { useRouter } from "next/navigation";
import Header from "../../components/header"; import Header from "../../components/header";
import { proxyMediaUrl } from "../../lib/media"; import { proxyMediaUrl } from "../../lib/media";
// ==========================================
// Types & Types Guards
// ==========================================
type SourceType = "twitter" | "pixiv" | "direct"; type SourceType = "twitter" | "pixiv" | "direct";
type PreviewItem = { type PreviewItem = {
@ -55,54 +58,65 @@ type ExistsApiResponse = {
documentId: string | null; documentId: string | null;
}; };
// ==========================================
// Helper Functions
// ==========================================
function detectSource(url: string): SourceType | null { function detectSource(url: string): SourceType | null {
if (/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//.test(url)) { if (/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//.test(url)) {
return "twitter"; return "twitter";
} }
if (/^https?:\/\/(www\.)?pixiv\.net\//.test(url)) { if (/^https?:\/\/(www\.)?pixiv\.net\//.test(url)) {
return "pixiv"; return "pixiv";
} }
return null; return null;
} }
function splitTags(text: string) { function splitTags(text: string): string[] {
return text return text
.split(/[\n,]/) .split(/[\n,]/)
.map((tag) => tag.trim().replace(/^#/, "")) .map((tag) => tag.trim().replace(/^#/, ""))
.filter((tag) => tag.length > 0); .filter((tag) => tag.length > 0);
} }
// ==========================================
// Main Component
// ==========================================
export default function AddPage() { export default function AddPage() {
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
// Form States
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [author, setAuthor] = useState(""); const [author, setAuthor] = useState("");
const [tagsText, setTagsText] = useState(""); const [tagsText, setTagsText] = useState("");
const [uploadMode, setUploadMode] = useState<"url" | "direct">("url");
// UI & API Response States
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]); const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
const [selected, setSelected] = useState<boolean[]>([]); const [selected, setSelected] = useState<boolean[]>([]);
const [sourceType, setSourceType] = useState<SourceType | null>(null); const [sourceType, setSourceType] = useState<SourceType | null>(null);
const [lastFetchedUrl, setLastFetchedUrl] = useState("");
const [existingDetailId, setExistingDetailId] = useState<string | null>(null);
// Global Status States
const [loadingPreview, setLoadingPreview] = useState(false); const [loadingPreview, setLoadingPreview] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [lastFetchedUrl, setLastFetchedUrl] = useState("");
// Auth States
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest"); const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
const [loadingRole, setLoadingRole] = useState(true); const [loadingRole, setLoadingRole] = useState(true);
const [existingDetailId, setExistingDetailId] = useState<string | null>(null);
const [uploadMode, setUploadMode] = useState<"url" | "direct">("url");
const fileInputRef = useRef<HTMLInputElement>(null);
const selectedCount = useMemo( // Memoized Values
() => selected.filter(Boolean).length, const selectedCount = useMemo(() => selected.filter(Boolean).length, [selected]);
[selected],
);
const canPreview = url.trim().length > 0 && !loadingPreview;
const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0;
const tags = useMemo(() => splitTags(tagsText), [tagsText]); const tags = useMemo(() => splitTags(tagsText), [tagsText]);
const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-"; const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-";
const canManagePost = viewerRole === "admin" || viewerRole === "writer"; 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(() => { useEffect(() => {
let active = true; let active = true;
@ -111,31 +125,52 @@ export default function AddPage() {
const response = await fetch("/api/auth/me", { cache: "no-store" }); const response = await fetch("/api/auth/me", { cache: "no-store" });
if (!response.ok) { if (!response.ok) {
if (active) setViewerRole("guest"); if (active) setViewerRole("guest");
window.location.href = "/api/auth/discord/login";
return; return;
} }
const me = (await response.json()) as Me; const me = (await response.json()) as Me;
if (active) { if (active) setViewerRole(me.role);
setViewerRole(me.role);
}
} catch { } catch {
if (active) { if (active) setViewerRole("guest");
setViewerRole("guest");
}
} finally { } finally {
if (active) { if (active) setLoadingRole(false);
setLoadingRole(false);
}
} }
} }
void loadMe(); 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() { function resetPreview() {
previewItems.forEach((item) => { previewItems.forEach((item) => {
if (item.file) URL.revokeObjectURL(item.url); 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)}`, { const response = await fetch(`/api/tweet/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
cache: "no-store", 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 data = (await response.json()) as TweetApiResponse;
const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || []; const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || [];
@ -200,47 +232,36 @@ export default function AddPage() {
})) }))
.filter((item) => item.url.length > 0); .filter((item) => item.url.length > 0);
if (items.length === 0) { if (items.length === 0) throw new Error("이미지를 찾지 못했습니다.");
throw new Error("이미지를 찾지 못했습니다.");
}
setPreviewItems(items); setPreviewItems(items);
setSelected(items.map(() => true)); setSelected(items.map(() => true));
if (!author.trim()) { if (!author.trim()) setAuthor(data.tweet?.author?.name ?? "");
setAuthor(data.tweet?.author?.name ?? "");
}
setLastFetchedUrl(trimmedUrl); setLastFetchedUrl(trimmedUrl);
return; return;
} }
// Pixiv Source
const response = await fetch(`/api/pixiv/fetch?url=${encodeURIComponent(trimmedUrl)}`, { const response = await fetch(`/api/pixiv/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
cache: "no-store", 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 data = (await response.json()) as PixivApiResponse;
const items = (data.image_proxy_urls ?? []) const items = (data.image_proxy_urls ?? [])
.filter((imageUrl): imageUrl is string => typeof imageUrl === "string" && imageUrl.length > 0) .filter((imageUrl): imageUrl is string => typeof imageUrl === "string" && imageUrl.length > 0)
.map((imageUrl) => ({ url: imageUrl })); .map((imageUrl) => ({ url: imageUrl }));
if (items.length === 0) { if (items.length === 0) throw new Error("이미지를 찾지 못했습니다.");
throw new Error("이미지를 찾지 못했습니다.");
}
setPreviewItems(items); setPreviewItems(items);
setSelected(items.map(() => true)); setSelected(items.map(() => true));
if (!author.trim()) { if (!author.trim()) setAuthor(data.author_name ?? "");
setAuthor(data.author_name ?? "");
}
if (!tagsText.trim()) { if (!tagsText.trim()) {
const pixivTags = (data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", "); setTagsText((data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", "));
setTagsText(pixivTags);
} }
setLastFetchedUrl(trimmedUrl); setLastFetchedUrl(trimmedUrl);
} catch (fetchError) { } catch (fetchError) {
setPreviewItems([]); setPreviewItems([]);
setSelected([]); setSelected([]);
@ -266,101 +287,50 @@ export default function AddPage() {
setSelected((prev) => [...prev, ...newItems.map(() => true)]); 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<HTMLFormElement>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
setSuccess(null); setSuccess(null);
if (!canManagePost) { if (!canManagePost) return setError("writer 또는 admin 권한이 필요합니다.");
setError("writer 또는 admin 권한이 필요합니다."); if (!sourceType) return setError("먼저 미리보기를 불러와 주세요.");
return; if (previewItems.length === 0) return setError("업로드할 이미지가 없습니다.");
} if (selectedCount === 0) return setError("최소 한 장 이상 선택해 주세요.");
if (!sourceType) {
setError("먼저 미리보기를 불러와 주세요.");
return;
}
if (previewItems.length === 0) {
setError("업로드할 이미지가 없습니다.");
return;
}
if (selectedCount === 0) {
setError("최소 한 장 이상 선택해 주세요.");
return;
}
setSubmitting(true); setSubmitting(true);
try { try {
const tags = splitTags(tagsText); const currentTags = splitTags(tagsText);
let response: Response; let response: Response;
if (uploadMode === "direct") { if (uploadMode === "direct") {
const formData = new FormData(); const formData = new FormData();
previewItems.forEach((item, index) => { previewItems.forEach((item, index) => {
if (selected[index] && item.file) { if (selected[index] && item.file) formData.append("files", item.file);
formData.append("files", item.file);
}
}); });
if (author.trim()) formData.append("author", author.trim()); 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", { response = await fetch("/api/post/upload/direct", { method: "POST", body: formData });
method: "POST",
body: formData,
});
} else { } else {
response = await fetch("/api/post/upload", { response = await fetch("/api/post/upload", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
url: url.trim(), url: url.trim(),
author: author.trim() || undefined, author: author.trim() || undefined,
tag: tags.length > 0 ? tags : undefined, tag: currentTags.length > 0 ? currentTags : undefined,
selected, selected,
}), }),
}); });
} }
let data: UploadApiResponse | null = null; let data: UploadApiResponse | null = null;
try { try { data = await response.json() as UploadApiResponse; } catch { data = null; }
data = (await response.json()) as UploadApiResponse;
} catch {
data = null;
}
const message = data?.message || `업로드 실패: ${response.status}`; const message = data?.message || `업로드 실패: ${response.status}`;
if (!response.ok || !data?.success) throw new Error(message);
if (!response.ok || !data?.success) { if ((data.savedCount ?? selectedCount) > 0) {
throw new Error(message);
}
const uploadedCount = data.savedCount ?? selectedCount;
if (uploadedCount > 0) {
setUrl(""); setUrl("");
setAuthor(""); setAuthor("");
setTagsText(""); setTagsText("");
@ -368,10 +338,8 @@ export default function AddPage() {
} }
setSuccess(message); 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) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다."); setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다.");
} finally { } finally {
@ -384,6 +352,7 @@ export default function AddPage() {
<Header /> <Header />
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8"> <main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
{/* Top Info Section */}
<section className="border-b border-border/70 pb-6"> <section className="border-b border-border/70 pb-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@ -401,56 +370,40 @@ export default function AddPage() {
</div> </div>
<form className="mt-5 space-y-6" onSubmit={submit}> <form className="mt-5 space-y-6" onSubmit={submit}>
{!loadingRole && !canManagePost ? ( {!loadingRole && !canManagePost && (
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"> <div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
. writer admin . . writer admin .
</div> </div>
) : null} )}
{/* Navigation Tabs */}
<div className="flex gap-6 border-b border-border/40"> <div className="flex gap-6 border-b border-border/40">
{(["url", "direct"] as const).map((mode) => (
<button <button
key={mode}
type="button" type="button"
onClick={() => { onClick={() => { setUploadMode(mode); resetPreview(); }}
setUploadMode("url"); className={`pb-2 text-sm font-medium transition ${uploadMode === mode ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
resetPreview();
}}
className={`pb-2 text-sm font-medium transition ${uploadMode === "url" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
> >
URL {mode === "url" ? "URL 가져오기" : "직접 업로드"}
</button>
<button
type="button"
onClick={() => {
setUploadMode("direct");
resetPreview();
}}
className={`pb-2 text-sm font-medium transition ${uploadMode === "direct" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
>
</button> </button>
))}
</div> </div>
{/* Input Area Group */}
{uploadMode === "url" ? ( {uploadMode === "url" ? (
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label> <label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input <input
id="url" id="url"
type="url" type="url"
value={url} value={url}
onChange={(event) => { onChange={(e) => { setUrl(e.target.value); resetPreview(); }}
setUrl(event.target.value);
if (previewItems.length > 0 || sourceType) {
resetPreview();
}
}}
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..." 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" 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} disabled={loadingPreview}
required={uploadMode === "url"} required
/> />
</div>
<p className="text-xs text-foreground/55">URL .</p> <p className="text-xs text-foreground/55">URL .</p>
</div> </div>
) : ( ) : (
@ -479,6 +432,7 @@ export default function AddPage() {
</div> </div>
)} )}
{/* Metadata Inputs */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="author"></label> <label className="block text-sm text-foreground/80" htmlFor="author"></label>
@ -486,7 +440,7 @@ export default function AddPage() {
id="author" id="author"
type="text" type="text"
value={author} 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" className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
/> />
<p className="text-xs text-red-600/50">* . .</p> <p className="text-xs text-red-600/50">* . .</p>
@ -497,29 +451,31 @@ export default function AddPage() {
id="tags" id="tags"
type="text" type="text"
value={tagsText} value={tagsText}
onChange={(event) => setTagsText(event.target.value)} onChange={(e) => setTagsText(e.target.value)}
placeholder=""
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40" className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
/> />
<p className="text-xs text-foreground/50">* . (,) .</p> <p className="text-xs text-foreground/50">* . (,) .</p>
</div> </div>
</div> </div>
{error ? <p className="text-sm text-red-600">{error}</p> : null} {/* Response Messages */}
{success ? <p className="text-sm text-emerald-600">{success} {existingDetailId ? ( {error && <p className="text-sm text-red-600">{error}</p>}
<Link {success && (
href={`/detail/${existingDetailId}`} <p className="text-sm text-emerald-600">
className="underline" {success}{" "}
> {existingDetailId && (
<Link href={`/detail/${existingDetailId}`} className="underline">
</Link> </Link>
) : null}</p> : null} )}
</p>
)}
{loadingPreview ? ( {loadingPreview && (
<div className="border-l-2 border-border/80 pl-3 text-xs text-foreground/65"> <div className="border-l-2 border-border/80 pl-3 text-xs text-foreground/65">
. . . .
</div> </div>
) : null} )}
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60"> <div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
<span>source: {sourceLabel}</span> <span>source: {sourceLabel}</span>
@ -527,7 +483,8 @@ export default function AddPage() {
<span>tags: {tags.length}</span> <span>tags: {tags.length}</span>
</div> </div>
{previewItems.length > 0 ? ( {/* Preview Asset Grid */}
{previewItems.length > 0 && (
<section className="mt-8 border-t border-border/70 pt-5"> <section className="mt-8 border-t border-border/70 pt-5">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-sm tracking-wide text-foreground/80">Preview</h2> <h2 className="text-sm tracking-wide text-foreground/80">Preview</h2>
@ -583,11 +540,11 @@ export default function AddPage() {
}} }}
/> />
</div> </div>
{selected[index] ? ( {selected[index] && (
<div className="pointer-events-none absolute right-2 top-2 border border-foreground bg-foreground px-2 py-0.5 text-[11px] text-background"> <div className="pointer-events-none absolute right-2 top-2 border border-foreground bg-foreground px-2 py-0.5 text-[11px] text-background">
selected selected
</div> </div>
) : null} )}
<div className="pointer-events-none absolute inset-x-2 bottom-2 truncate border border-white/20 bg-black/65 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100"> <div className="pointer-events-none absolute inset-x-2 bottom-2 truncate border border-white/20 bg-black/65 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100">
#{index + 1} #{index + 1}
</div> </div>
@ -595,8 +552,9 @@ export default function AddPage() {
))} ))}
</div> </div>
</section> </section>
) : null} )}
{/* Submit Action */}
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
<button <button
type="submit" type="submit"
@ -608,8 +566,6 @@ export default function AddPage() {
</div> </div>
</form> </form>
</section> </section>
</main> </main>
</div> </div>
); );

View file

@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import "react-photo-album/masonry.css"; import "react-photo-album/masonry.css";
import { ThemeProvider } from "../components/theme-provider"; import { ThemeProvider } from "../components/theme-provider";
import KeyboardShortcuts from "../components/keyboard-shortcuts";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Akiyama Mizuki", title: "Akiyama Mizuki",
@ -23,6 +23,7 @@ export default function RootLayout({
> >
<body className="min-h-full flex flex-col bg-background text-foreground transition-colors duration-300"> <body className="min-h-full flex flex-col bg-background text-foreground transition-colors duration-300">
<ThemeProvider> <ThemeProvider>
<KeyboardShortcuts />
{children} {children}
</ThemeProvider> </ThemeProvider>
</body> </body>

View file

@ -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;
}