From b18cff8b1a41b89b6ef0d5a24d528d7e3bd50250 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sat, 18 Apr 2026 06:48:21 +0900 Subject: [PATCH] feat: implement post existence check and detail page --- apps/backend/src/lib/pixiv.ts | 27 ++- apps/backend/src/lib/post.ts | 49 ++++ apps/backend/src/routes/auth.ts | 6 +- apps/backend/src/routes/post.ts | 114 +++++++-- apps/frontend/src/app/add/page.tsx | 74 +++++- apps/frontend/src/app/detail/[id]/page.tsx | 225 ++++++++++++++++++ apps/frontend/src/app/not-found.tsx | 15 ++ apps/frontend/src/app/page.tsx | 7 +- .../src/components/detail-raw-panel.tsx | 120 ++++++++++ apps/frontend/src/components/header.tsx | 52 +++- 10 files changed, 646 insertions(+), 43 deletions(-) create mode 100644 apps/backend/src/lib/post.ts create mode 100644 apps/frontend/src/app/detail/[id]/page.tsx create mode 100644 apps/frontend/src/app/not-found.tsx create mode 100644 apps/frontend/src/components/detail-raw-panel.tsx diff --git a/apps/backend/src/lib/pixiv.ts b/apps/backend/src/lib/pixiv.ts index 745d69e..d300da5 100644 --- a/apps/backend/src/lib/pixiv.ts +++ b/apps/backend/src/lib/pixiv.ts @@ -1,3 +1,5 @@ +import { MediaUpload } from "@/models/media"; + function fetchPixivData(url: string): Promise { // https://www.pixiv.net/artworks/143552616 const match = url.match(/\/artworks\/(\d+)/); @@ -30,4 +32,27 @@ function fetchPixivData(url: string): Promise { }); } -export { fetchPixivData }; \ No newline at end of file +async function checkPixivData(url: string, selected: Array) { + const match = url.match(/\/artworks\/(\d+)/); + if (!match) { + throw new Error("Invalid Pixiv URL"); + } + + const artworkId = match[1]; + const selectedIndices = selected + .map((isSelected, index) => (isSelected ? index : -1)) + .filter((index) => index >= 0); + + if (selectedIndices.length === 0) { + return false; + } + + const existing = await MediaUpload.findOne({ + "tweet.id": artworkId, + mediaIndex: { $in: selectedIndices }, + }); + + return existing !== null; +} + +export { checkPixivData, fetchPixivData }; \ No newline at end of file diff --git a/apps/backend/src/lib/post.ts b/apps/backend/src/lib/post.ts new file mode 100644 index 0000000..0cc256a --- /dev/null +++ b/apps/backend/src/lib/post.ts @@ -0,0 +1,49 @@ +import { MediaUpload } from "@/models/media"; + +type PostSource = "twitter" | "pixiv"; + +function parsePostUrl(url: string): { source: PostSource; postId: string } | null { + const tweetMatch = url.match(/\/status\/(\d+)/); + if (tweetMatch) { + return { + source: "twitter", + postId: tweetMatch[1], + }; + } + + const pixivMatch = url.match(/\/artworks\/(\d+)/); + if (pixivMatch) { + return { + source: "pixiv", + postId: pixivMatch[1], + }; + } + + return null; +} + +async function checkExistingPostByUrl(url: string) { + const parsed = parsePostUrl(url); + if (!parsed) { + return { + exists: false, + source: null as PostSource | null, + postId: null as string | null, + documentId: null as string | null, + }; + } + + const existing = await MediaUpload.findOne({ + type: parsed.source, + "tweet.id": parsed.postId, + }); + + return { + exists: existing !== null, + source: parsed.source, + postId: parsed.postId, + documentId: existing?._id?.toString() ?? null, + }; +} + +export { checkExistingPostByUrl, parsePostUrl }; \ No newline at end of file diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index e7a44dc..108e70d 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -53,7 +53,7 @@ export default new Elysia({ prefix: "/auth" }) value: "", httpOnly: true, maxAge: 0, - path: "/api", + path: "/", }); return { ok: true }; @@ -141,7 +141,7 @@ export default new Elysia({ prefix: "/auth" }) value: token, httpOnly: true, maxAge: 60 * 60 * 24 * 7, // 7 days - path: "/api", + path: "/", }); return redirect("/"); @@ -203,7 +203,7 @@ export default new Elysia({ prefix: "/auth" }) value: nextToken, httpOnly: true, maxAge: 60 * 60 * 24 * 7, - path: "/api", + path: "/", }); } diff --git a/apps/backend/src/routes/post.ts b/apps/backend/src/routes/post.ts index 02af917..513918c 100644 --- a/apps/backend/src/routes/post.ts +++ b/apps/backend/src/routes/post.ts @@ -6,12 +6,56 @@ import { Tag } from "@/models/tag"; import { User } from "@/models/user"; import { createAuditLog } from "@/lib/audit"; import { normalizeQueryTags, normalizeTags } from "@/lib/tag"; -import { checkTweetData, fetchTweetData } from "@/lib/tweet"; +import { fetchTweetData } from "@/lib/tweet"; import { fetchPixivData } from "@/lib/pixiv"; +import { checkExistingPostByUrl } from "@/lib/post"; import { makeS3FileName, s3Client, uploadToS3 } from "@/lib/s3"; const inFlightUploads = new Set(); +type UploadResponse = { + success: boolean; + message: string; + savedCount?: number; + failedCount?: number; +}; + +type ExistsResponse = { + success: boolean; + message: string; + exists: boolean; + source: "twitter" | "pixiv" | null; + postId: string | null; + documentId: string | null; +}; + +function uploadOk(message: string, extra?: Omit): UploadResponse { + return { + success: true, + message, + ...extra, + }; +} + +function uploadError(message: string, extra?: Omit): UploadResponse { + return { + success: false, + message, + ...extra, + }; +} + +function existsResponse(message: string, exists: boolean, source: ExistsResponse["source"], postId: string | null, documentId: string | null): ExistsResponse { + return { + success: true, + message, + exists, + source, + postId, + documentId, + }; +} + function buildUploadKey(url: string, selected: boolean[]) { const match = url.match(/\/status\/(\d+)/); const tweetId = match?.[1] ?? url; @@ -169,6 +213,22 @@ export default new Elysia({ prefix: "/post" }) return tags; }) + .get("/exists", async ({ query }) => { + const result = await checkExistingPostByUrl(query.url); + + return existsResponse( + result.exists ? "이미 저장된 게시물입니다." : "저장된 게시물이 없습니다.", + result.exists, + result.source, + result.postId, + result.documentId, + ); + }, { + query: t.Object({ + url: t.String(), + }), + }) + .get("/detail/:id", async ({ params, status }) => { const post = await MediaUpload.findById(params.id); if (!post) { @@ -323,13 +383,18 @@ export default new Elysia({ prefix: "/post" }) return status(403, "Forbidden"); } + const existingPost = await checkExistingPostByUrl(body.url); + if (existingPost.exists) { + return uploadOk("이미 저장된 게시물입니다.", { savedCount: 0, failedCount: 0 }); + } + if (body.url.startsWith("https://www.pixiv.net/")) { 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, "이미 처리 중인 업로드입니다."); + return status(202, uploadError("이미 처리 중인 업로드입니다.")); } inFlightUploads.add(uploadKey); @@ -342,7 +407,7 @@ export default new Elysia({ prefix: "/post" }) : []; if (mediaUrls.length === 0) { - return status(400, "No media found in the Pixiv artwork."); + return status(400, uploadError("No media found in the Pixiv artwork.")); } const normalizedTags = normalizeTags(body.tag ?? ["미분류"]); @@ -430,20 +495,26 @@ export default new Elysia({ prefix: "/post" }) }); console.log(`[Pixiv upload finished] requestId=${requestId} key=${uploadKey}`); if (savedCount === 0 && failedCount > 0) { - return status(502, "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요."); + return status(502, uploadError("업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.", { + savedCount, + failedCount, + })); } if (savedCount === 0) { - return status(400, "선택된 이미지가 없습니다."); + return status(400, uploadError("선택된 이미지가 없습니다.", { + savedCount, + failedCount, + })); } - return { + return uploadOk("업로드가 완료되었습니다.", { savedCount, failedCount, - }; + }); } catch (error) { console.error(`[Pixiv upload aborted] requestId=${requestId} key=${uploadKey}`, error); - return status(500, "Failed to fetch Pixiv data"); + return status(500, uploadError("Failed to fetch Pixiv data")); } finally { inFlightUploads.delete(uploadKey); } @@ -453,17 +524,12 @@ export default new Elysia({ prefix: "/post" }) if (inFlightUploads.has(uploadKey)) { console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`); - return status(202, "이미 처리 중인 업로드입니다."); + return status(202, uploadError("이미 처리 중인 업로드입니다.")); } inFlightUploads.add(uploadKey); console.log(`[Upload started] requestId=${requestId} key=${uploadKey}`); - if (await checkTweetData(body.url, body.selected)) { - inFlightUploads.delete(uploadKey); - console.log(`[Upload skipped-existing] requestId=${requestId} key=${uploadKey}`); - return "이미 저장된 트윗입니다."; - } try { const tweetData = await fetchTweetData(body.url); let savedCount = 0; @@ -544,31 +610,37 @@ export default new Elysia({ prefix: "/post" }) }, }); } else { - return status(400, "트윗에서 이미지를 찾지 못했습니다."); + return status(400, uploadError("트윗에서 이미지를 찾지 못했습니다.")); } } console.log(`[Upload finished] requestId=${requestId} key=${uploadKey}`); if (savedCount === 0 && failedCount > 0) { - return status(502, "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요."); + return status(502, uploadError("업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.", { + savedCount, + failedCount, + })); } if (savedCount === 0) { - return status(400, "선택된 이미지가 없습니다."); + return status(400, uploadError("선택된 이미지가 없습니다.", { + savedCount, + failedCount, + })); } - return { + return uploadOk("업로드가 완료되었습니다.", { savedCount, failedCount, - }; + }); } catch (error) { console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error); console.error(error); - return status(500, "Failed to fetch tweet data"); + return status(500, uploadError("Failed to fetch tweet data")); } finally { inFlightUploads.delete(uploadKey); } } else { - return status(400, "어..."); + return status(400, uploadError("어...")); } }, { body: t.Object({ diff --git a/apps/frontend/src/app/add/page.tsx b/apps/frontend/src/app/add/page.tsx index 68c86a4..ca5087c 100644 --- a/apps/frontend/src/app/add/page.tsx +++ b/apps/frontend/src/app/add/page.tsx @@ -1,6 +1,7 @@ "use client"; import { FormEvent, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import Header from "../../components/header"; type SourceType = "twitter" | "pixiv"; @@ -26,6 +27,22 @@ type Me = { role: "admin" | "writer" | "reader"; }; +type UploadApiResponse = { + success: boolean; + message: string; + savedCount?: number; + failedCount?: number; +}; + +type ExistsApiResponse = { + success: boolean; + message: string; + exists: boolean; + source: SourceType | null; + postId: string | null; + documentId: string | null; +}; + function detectSource(url: string): SourceType | null { if (/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//.test(url)) { return "twitter"; @@ -59,6 +76,7 @@ export default function AddPage() { const [lastFetchedUrl, setLastFetchedUrl] = useState(""); const [viewerRole, setViewerRole] = useState("guest"); const [loadingRole, setLoadingRole] = useState(true); + const [existingDetailId, setExistingDetailId] = useState(null); const selectedCount = useMemo( () => selected.filter(Boolean).length, @@ -110,6 +128,7 @@ export default function AddPage() { setLastFetchedUrl(""); setError(null); setSuccess(null); + setExistingDetailId(null); } async function fetchPreview(targetUrl?: string) { @@ -125,8 +144,25 @@ export default function AddPage() { setLoadingPreview(true); setSourceType(source); + setExistingDetailId(null); try { + const existsResponse = await fetch(`/api/post/exists?url=${encodeURIComponent(trimmedUrl)}`, { + cache: "no-store", + }); + + if (existsResponse.ok) { + const existsData = (await existsResponse.json()) as ExistsApiResponse; + if (existsData.exists) { + setPreviewItems([]); + setSelected([]); + setLastFetchedUrl(trimmedUrl); + setExistingDetailId(existsData.documentId); + setSuccess(existsData.message); + return; + } + } + if (source === "twitter") { const response = await fetch(`/api/tweet/fetch?url=${encodeURIComponent(trimmedUrl)}`, { cache: "no-store", @@ -255,17 +291,28 @@ export default function AddPage() { }), }); - if (!response.ok) { - const responseText = await response.text(); - throw new Error(responseText || `업로드 실패: ${response.status}`); + let data: UploadApiResponse | null = null; + try { + data = (await response.json()) as UploadApiResponse; + } catch { + data = null; } - const uploadedCount = selectedCount; - setUrl(""); - setAuthor(""); - setTagsText(""); - resetPreview(); - setSuccess(`${uploadedCount}개 이미지 업로드를 요청했습니다.`); + const message = data?.message || `업로드 실패: ${response.status}`; + + if (!response.ok || !data?.success) { + throw new Error(message); + } + + const uploadedCount = data.savedCount ?? selectedCount; + if (uploadedCount > 0) { + setUrl(""); + setAuthor(""); + setTagsText(""); + resetPreview(); + } + + setSuccess(message); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다."); } finally { @@ -351,7 +398,14 @@ export default function AddPage() { {error ?

{error}

: null} - {success ?

{success}

: null} + {success ?

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

: null} {loadingPreview ? (
diff --git a/apps/frontend/src/app/detail/[id]/page.tsx b/apps/frontend/src/app/detail/[id]/page.tsx new file mode 100644 index 0000000..8ec81f0 --- /dev/null +++ b/apps/frontend/src/app/detail/[id]/page.tsx @@ -0,0 +1,225 @@ +import Link from "next/link"; +import { cookies, headers } from "next/headers"; +import { notFound } from "next/navigation"; +import Header from "@/components/header"; +import DetailRawPanel from "@/components/detail-raw-panel"; + +type SourceType = "twitter" | "pixiv"; + +type PostDetailResponse = { + _id: string; + type: SourceType; + url?: string; + author?: string; + tags?: string[]; + mediaUrl?: string; + mediaIndex?: number; +}; + +type Me = { + role: "admin" | "writer" | "reader"; +}; + +async function getApiBaseUrl() { + const configuredBaseUrl = process.env.API_BASE_URL; + if (configuredBaseUrl) { + return configuredBaseUrl; + } + + const requestHeaders = await headers(); + const protocol = requestHeaders.get("x-forwarded-proto") ?? "http"; + const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"); + + if (!host) { + throw new Error("Unable to determine request host for server-side fetches."); + } + + return `${protocol}://${host}`; +} + +async function fetchPostDetail(apiBaseUrl: string, id: string) { + const response = await fetch(`${apiBaseUrl}/api/post/detail/${id}`, { + cache: "no-store", + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`상세 정보를 불러오지 못했습니다: ${response.status}`); + } + + return response.json() as Promise; +} + +async function fetchViewerRole(apiBaseUrl: string) { + const cookieHeader = (await cookies()).toString(); + if (!cookieHeader) { + return "guest" as const; + } + + try { + const response = await fetch(`${apiBaseUrl}/api/auth/me`, { + cache: "no-store", + headers: { + cookie: cookieHeader, + }, + }); + + if (!response.ok) { + return "guest" as const; + } + + const me = (await response.json()) as Me; + return me.role; + } catch { + return "guest" as const; + } +} + +export default async function DetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const apiBaseUrl = await getApiBaseUrl(); + const postPromise = fetchPostDetail(apiBaseUrl, id); + const viewerRolePromise = fetchViewerRole(apiBaseUrl); + + let post: PostDetailResponse | null; + try { + post = await postPromise; + } catch (loadError) { + return ( +
+
+
+
+
+
+

Detail

+
+
+

ID: {id}

+
+
+

+ {loadError instanceof Error ? loadError.message : "상세 정보를 불러오지 못했습니다."} +

+
+ + 돌아가기 + +
+
+
+
+ ); + } + + if (!post) { + notFound(); + } + + const viewerRole = await viewerRolePromise; + const canManagePost = viewerRole === "admin" || viewerRole === "writer"; + const sourceLabel = post.type === "twitter" ? "𝕏" : post.type === "pixiv" ? "Pixiv" : "-"; + const tags = post.tags ?? []; + + return ( +
+
+ +
+
+
+
+

Detail

+
+
+

Source: {sourceLabel}

+

ID: {id}

+
+
+ +
+
+ {post.mediaUrl ? ( + {post.author + ) : ( +
+ )} +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/not-found.tsx b/apps/frontend/src/app/not-found.tsx new file mode 100644 index 0000000..1a34057 --- /dev/null +++ b/apps/frontend/src/app/not-found.tsx @@ -0,0 +1,15 @@ +export default function NotFound() { + return ( +
+
+ + Not Found Image + +
+

404

+

괜찮아요. 이런날도 있는거죠

+
+ ← Go Home +
+ ) +} diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index b820a9c..2661c8d 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -27,7 +27,6 @@ type GalleryPhoto = { width: number; height: number; key: string; - href: string; alt: string; author: string; }; @@ -413,7 +412,6 @@ export default function App() { width: image.naturalWidth, height: image.naturalHeight, key: upload._id, - href: upload.tweet.url, alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`, author: upload.author?.trim() || "unknown", }); @@ -424,7 +422,6 @@ export default function App() { width: 1, height: 1, key: upload._id, - href: upload.mediaUrl, alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`, author: upload.author?.trim() || "unknown", }); @@ -499,7 +496,7 @@ export default function App() { photo: ({ onClick }, { photo }) => ( { event.preventDefault(); @@ -508,8 +505,6 @@ export default function App() { const safeY = Math.min(event.clientY, window.innerHeight - 220); setContextMenu({ x: Math.max(12, safeX), y: Math.max(12, safeY), photo }); }} - target="_blank" - rel="noopener noreferrer" className="group relative block overflow-hidden image-scale" title={`작가 ${photo.author}`} > diff --git a/apps/frontend/src/components/detail-raw-panel.tsx b/apps/frontend/src/components/detail-raw-panel.tsx new file mode 100644 index 0000000..e9184ab --- /dev/null +++ b/apps/frontend/src/components/detail-raw-panel.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; + +type RawJsonPanelProps = { + data: unknown; +}; + +function renderJsonValue(value: unknown, indentLevel = 0): ReactNode { + const indent = " ".repeat(indentLevel); + + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return []; + } + + return ( + <> + [ + + {value.map((item, index) => ( + + {indent} + {renderJsonValue(item, indentLevel + 1)} + {index < value.length - 1 ? , : null} + + ))} + + {indent}] + + ); + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + + if (entries.length === 0) { + return {`{}`}; + } + + return ( + <> + {`{`} + + {entries.map(([key, childValue], index) => ( + + {indent} + "{key}" + : + {renderJsonValue(childValue, indentLevel + 1)} + {index < entries.length - 1 ? , : null} + + ))} + + {indent}{"}"} + + ); + } + + if (typeof value === "string") { + return "{value}"; + } + + if (typeof value === "number") { + return {value}; + } + + if (typeof value === "boolean") { + return {String(value)}; + } + + return {String(value)}; +} + +export default function DetailRawPanel({ data }: RawJsonPanelProps) { + const [isDeveloper, setIsDeveloper] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const syncDeveloperState = () => { + setIsDeveloper(window.localStorage.getItem("developer") === "true"); + }; + + syncDeveloperState(); + window.addEventListener("storage", syncDeveloperState); + window.addEventListener("focus", syncDeveloperState); + + return () => { + window.removeEventListener("storage", syncDeveloperState); + window.removeEventListener("focus", syncDeveloperState); + }; + }, []); + + if (!isDeveloper) { + return null; + } + + return ( +
+ + {isOpen ? ( +
+                    {renderJsonValue(data)}
+                
+ ) : null} +
+ ); +} diff --git a/apps/frontend/src/components/header.tsx b/apps/frontend/src/components/header.tsx index 20f60a6..ff51b3e 100644 --- a/apps/frontend/src/components/header.tsx +++ b/apps/frontend/src/components/header.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useEffect, useRef, useState } from "react"; type Me = { @@ -27,6 +28,53 @@ export default function Header() { const [me, setMe] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); + const developerClickTimeoutRef = useRef(null); + + function handleIconClick() { + if (typeof window === "undefined") { + return; + } + + const clickCountKey = "developer_icon_click_count"; + const lastClickAtKey = "developer_icon_click_last_at"; + const developerKey = "developer"; + const now = Date.now(); + const lastClickAt = Number(window.localStorage.getItem(lastClickAtKey) ?? "0"); + const previousCount = Number(window.localStorage.getItem(clickCountKey) ?? "0"); + const nextCount = now - lastClickAt > 1500 ? 1 : previousCount + 1; + + window.localStorage.setItem(clickCountKey, String(nextCount)); + window.localStorage.setItem(lastClickAtKey, String(now)); + + if (developerClickTimeoutRef.current !== null) { + window.clearTimeout(developerClickTimeoutRef.current); + } + + developerClickTimeoutRef.current = window.setTimeout(() => { + window.localStorage.removeItem(clickCountKey); + window.localStorage.removeItem(lastClickAtKey); + developerClickTimeoutRef.current = null; + }, 1500); + + if (nextCount >= 10) { + window.localStorage.setItem(developerKey, "true"); + window.localStorage.removeItem(clickCountKey); + window.localStorage.removeItem(lastClickAtKey); + + if (developerClickTimeoutRef.current !== null) { + window.clearTimeout(developerClickTimeoutRef.current); + developerClickTimeoutRef.current = null; + } + } + } + + useEffect(() => { + return () => { + if (developerClickTimeoutRef.current !== null) { + window.clearTimeout(developerClickTimeoutRef.current); + } + }; + }, []); useEffect(() => { let active = true; @@ -91,11 +139,11 @@ export default function Header() { return (
- 🎀 + 🎀 {me ? (