feat: add global 'a' keyboard shortcut for navigation and refactor backend routes for improved code structure and authentication logic
This commit is contained in:
parent
7d698786d0
commit
89c831cba4
5 changed files with 405 additions and 759 deletions
|
|
@ -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<string>();
|
||||
|
||||
// ==========================================
|
||||
// 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, "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: {
|
||||
title: 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[]) {
|
||||
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";
|
||||
})
|
||||
.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 }) => {
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const count = await MediaUpload.countDocuments(filter);
|
||||
return count;
|
||||
return await MediaUpload.countDocuments(filterTags.length > 0 ? { tags: { $in: filterTags } } : {});
|
||||
}, {
|
||||
query: t.Object({
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
query: t.Object({ tags: t.Optional(t.Union([t.String(), t.Array(t.String())])) })
|
||||
})
|
||||
|
||||
.get("/list", async ({ query, jwt, cookie: { mizuki }, status }) => {
|
||||
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");
|
||||
}
|
||||
.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) {
|
||||
if (result.deletedCount !== 1) return status(404, "포스트를 찾을 수 없습니다.");
|
||||
|
||||
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.delete",
|
||||
targetType: "post",
|
||||
targetId: params.id,
|
||||
summary: `${requester.username} deleted a post`,
|
||||
detail: {
|
||||
id: params.id,
|
||||
mediaUrl: targetPost?.mediaUrl,
|
||||
},
|
||||
detail: { id: params.id, mediaUrl: targetPost?.mediaUrl },
|
||||
});
|
||||
|
||||
return "삭제되었습니다.";
|
||||
} else {
|
||||
return status(404, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
}, {
|
||||
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 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 uploadKey = buildUploadKey(body.url, body.selected);
|
||||
|
||||
if (inFlightUploads.has(uploadKey)) {
|
||||
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
|
||||
return status(202, uploadError("이미 처리 중인 업로드입니다."));
|
||||
return status(202, createUploadResponse(false, "이미 처리 중인 업로드입니다."));
|
||||
}
|
||||
|
||||
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 {
|
||||
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,8 +363,7 @@ export default new Elysia({ prefix: "/post" })
|
|||
fileName,
|
||||
mediaUrl,
|
||||
mediaIndex: index,
|
||||
createDocument: async () => {
|
||||
return await MediaUpload.create({
|
||||
createDocument: () => MediaUpload.create({
|
||||
type: "pixiv",
|
||||
tweet: {
|
||||
id: illustId,
|
||||
|
|
@ -509,140 +378,45 @@ export default new Elysia({ prefix: "/post" })
|
|||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
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.ok) { failedCount++; continue; }
|
||||
if (result.id) savedIds.push(result.id);
|
||||
if (result.created) { await saveTags(normalizedTags); savedCount++; }
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
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 { 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({
|
||||
createDocument: () => MediaUpload.create({
|
||||
type: "twitter",
|
||||
tweet: tweetWithoutMedia,
|
||||
mediaIndex: index,
|
||||
|
|
@ -652,100 +426,47 @@ export default new Elysia({ prefix: "/post" })
|
|||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
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.ok) { failedCount++; continue; }
|
||||
if (result.id) savedIds.push(result.id);
|
||||
if (result.created) { await saveTags(normalizedTags); savedCount++; }
|
||||
}
|
||||
|
||||
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,
|
||||
url: savedIds[0] ? `${config.base_url}/detail/${savedIds[0]}` : body.url,
|
||||
author: body.author ? body.author : tweetData.tweet.author.name,
|
||||
tags: normalizeTags(body.tag || ["미분류"]),
|
||||
imageUrl: firstMedia.url,
|
||||
tags: normalizedTags,
|
||||
imageUrl: media[0].url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.upload.twitter",
|
||||
targetId: tweetData.tweet.id,
|
||||
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 Twitter media`,
|
||||
detail: {
|
||||
url: body.url,
|
||||
savedCount,
|
||||
failedCount,
|
||||
},
|
||||
summary: `${requester.username} uploaded ${isPixiv ? "Pixiv" : "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 (savedCount === 0) {
|
||||
return status(400, uploadError("선택된 이미지가 없습니다.", {
|
||||
savedCount,
|
||||
failedCount,
|
||||
}));
|
||||
return status(failedCount > 0 ? 502 : 400, createUploadResponse(false, failedCount > 0 ? "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요." : "선택된 이미지가 없습니다.", { savedCount, failedCount }));
|
||||
}
|
||||
|
||||
return uploadOk("업로드가 완료되었습니다.", {
|
||||
savedCount,
|
||||
failedCount,
|
||||
ids: savedIds,
|
||||
});
|
||||
return createUploadResponse(true, "업로드가 완료되었습니다.", { 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"));
|
||||
return status(500, createUploadResponse(false, `Failed to fetch ${isPixiv ? "Pixiv" : "Twitter"} data`));
|
||||
} finally {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
}
|
||||
} else {
|
||||
return status(400, uploadError("어..."));
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
url: t.String(),
|
||||
|
|
@ -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" });
|
||||
});
|
||||
|
|
@ -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/",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(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<PreviewItem[]>([]);
|
||||
const [selected, setSelected] = useState<boolean[]>([]);
|
||||
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 [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = 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 [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(
|
||||
() => 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<HTMLFormElement>) {
|
||||
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() {
|
|||
<Header />
|
||||
|
||||
<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">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
|
|
@ -401,56 +370,40 @@ export default function AddPage() {
|
|||
</div>
|
||||
|
||||
<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">
|
||||
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-6 border-b border-border/40">
|
||||
{(["url", "direct"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadMode("url");
|
||||
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"}`}
|
||||
onClick={() => { setUploadMode(mode); resetPreview(); }}
|
||||
className={`pb-2 text-sm font-medium transition ${uploadMode === mode ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
|
||||
>
|
||||
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"}`}
|
||||
>
|
||||
직접 업로드
|
||||
{mode === "url" ? "URL 가져오기" : "직접 업로드"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input Area Group */}
|
||||
{uploadMode === "url" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
|
||||
if (previewItems.length > 0 || sourceType) {
|
||||
resetPreview();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => { 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={uploadMode === "url"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/55">URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -479,6 +432,7 @@ export default function AddPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata Inputs */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="author">작가</label>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
<p className="text-xs text-red-600/50">* 자동으로 작가를 가져옵니다. 만약 잘못된 이름이라면 수동으로 수정할 수 있습니다.</p>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
<p className="text-xs text-foreground/50">* 자동으로 태그를 가져옵니다. 쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{success ? <p className="text-sm text-emerald-600">{success} {existingDetailId ? (
|
||||
<Link
|
||||
href={`/detail/${existingDetailId}`}
|
||||
className="underline"
|
||||
>
|
||||
{/* Response Messages */}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{success && (
|
||||
<p className="text-sm text-emerald-600">
|
||||
{success}{" "}
|
||||
{existingDetailId && (
|
||||
<Link href={`/detail/${existingDetailId}`} className="underline">
|
||||
상세 페이지로 이동
|
||||
</Link>
|
||||
) : null}</p> : null}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loadingPreview ? (
|
||||
{loadingPreview && (
|
||||
<div className="border-l-2 border-border/80 pl-3 text-xs text-foreground/65">
|
||||
미리보기를 불러오는 중입니다. 이미지 개수에 따라 몇 초 정도 소요될 수 있습니다.
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
|
||||
<span>source: {sourceLabel}</span>
|
||||
|
|
@ -527,7 +483,8 @@ export default function AddPage() {
|
|||
<span>tags: {tags.length}</span>
|
||||
</div>
|
||||
|
||||
{previewItems.length > 0 ? (
|
||||
{/* Preview Asset Grid */}
|
||||
{previewItems.length > 0 && (
|
||||
<section className="mt-8 border-t border-border/70 pt-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm tracking-wide text-foreground/80">Preview</h2>
|
||||
|
|
@ -583,11 +540,11 @@ export default function AddPage() {
|
|||
}}
|
||||
/>
|
||||
</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">
|
||||
selected
|
||||
</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">
|
||||
#{index + 1}
|
||||
</div>
|
||||
|
|
@ -595,8 +552,9 @@ export default function AddPage() {
|
|||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Submit Action */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -608,8 +566,6 @@ export default function AddPage() {
|
|||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground transition-colors duration-300">
|
||||
<ThemeProvider>
|
||||
<KeyboardShortcuts />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
|
|
|||
31
apps/frontend/src/components/keyboard-shortcuts.tsx
Normal file
31
apps/frontend/src/components/keyboard-shortcuts.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue