feat: implement post existence check and detail page
This commit is contained in:
parent
55af0549e7
commit
b18cff8b1a
10 changed files with 646 additions and 43 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import { MediaUpload } from "@/models/media";
|
||||
|
||||
function fetchPixivData(url: string): Promise<any> {
|
||||
// https://www.pixiv.net/artworks/143552616
|
||||
const match = url.match(/\/artworks\/(\d+)/);
|
||||
|
|
@ -30,4 +32,27 @@ function fetchPixivData(url: string): Promise<any> {
|
|||
});
|
||||
}
|
||||
|
||||
export { fetchPixivData };
|
||||
async function checkPixivData(url: string, selected: Array<boolean>) {
|
||||
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 };
|
||||
49
apps/backend/src/lib/post.ts
Normal file
49
apps/backend/src/lib/post.ts
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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: "/",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
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, "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;
|
||||
|
|
@ -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 {
|
||||
return status(400, uploadError("선택된 이미지가 없습니다.", {
|
||||
savedCount,
|
||||
failedCount,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
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 {
|
||||
return status(400, uploadError("선택된 이미지가 없습니다.", {
|
||||
savedCount,
|
||||
failedCount,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<Me["role"] | "guest">("guest");
|
||||
const [loadingRole, setLoadingRole] = useState(true);
|
||||
const [existingDetailId, setExistingDetailId] = useState<string | null>(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;
|
||||
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(`${uploadedCount}개 이미지 업로드를 요청했습니다.`);
|
||||
}
|
||||
|
||||
setSuccess(message);
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다.");
|
||||
} finally {
|
||||
|
|
@ -351,7 +398,14 @@ export default function AddPage() {
|
|||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
|
||||
{success ? <p className="text-sm text-emerald-600">{success} {existingDetailId ? (
|
||||
<Link
|
||||
href={`/detail/${existingDetailId}`}
|
||||
className="underline"
|
||||
>
|
||||
상세 페이지로 이동
|
||||
</Link>
|
||||
) : null}</p> : null}
|
||||
|
||||
{loadingPreview ? (
|
||||
<div className="border-l-2 border-border/80 pl-3 text-xs text-foreground/65">
|
||||
|
|
|
|||
225
apps/frontend/src/app/detail/[id]/page.tsx
Normal file
225
apps/frontend/src/app/detail/[id]/page.tsx
Normal file
|
|
@ -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<PostDetailResponse>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
|
||||
<Header />
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="border-b border-border/70 pb-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl tracking-wide">Detail</h1>
|
||||
</div>
|
||||
<div className="border-l border-border/80 pl-3 text-xs text-foreground/70">
|
||||
<p>ID: <span className="font-medium text-foreground/90">{id}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-red-600">
|
||||
{loadError instanceof Error ? loadError.message : "상세 정보를 불러오지 못했습니다."}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/" className="border border-border px-4 py-2 text-sm text-foreground/70 transition hover:bg-black/5">
|
||||
돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
|
||||
<Header />
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="border-b border-border/70 pb-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl tracking-wide">Detail</h1>
|
||||
</div>
|
||||
<div className="border-l border-border/80 pl-3 text-xs text-foreground/70">
|
||||
<p>Source: <span className="font-medium text-foreground/90">{sourceLabel}</span></p>
|
||||
<p className="mt-0.5">ID: <span className="font-medium text-foreground/90">{id}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||||
<div className="space-y-3">
|
||||
{post.mediaUrl ? (
|
||||
<img
|
||||
src={post.mediaUrl}
|
||||
alt={post.author ?? post._id}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className="w-full border border-border bg-white object-contain image-scale"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-4/5 w-full border border-border bg-black/5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="border border-border bg-background/80 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-foreground/40">Metadata</p>
|
||||
<dl className="mt-3 space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-foreground/45">Author</dt>
|
||||
<dd className="mt-0.5 text-foreground/90">{post.author?.trim() || "unknown"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-foreground/45">Type</dt>
|
||||
<dd className="mt-0.5 text-foreground/90">{post.type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-foreground/45">Media Index</dt>
|
||||
<dd className="mt-0.5 text-foreground/90">{typeof post.mediaIndex === "number" ? post.mediaIndex + 1 : "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="border border-border bg-background/80 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-foreground/40">Tags</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.length > 0 ? tags.map((tag) => (
|
||||
<span key={tag} className="border border-border/80 px-2 py-1 text-xs text-foreground/75">
|
||||
{tag}
|
||||
</span>
|
||||
)) : (
|
||||
<span className="text-sm text-foreground/55">태그 없음</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{post.url ? (
|
||||
<a
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border border-border px-4 py-2 text-sm text-foreground/80 transition hover:bg-black/5"
|
||||
>
|
||||
원본 보기
|
||||
</a>
|
||||
) : null}
|
||||
<div className="flex flex-row gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="border border-border px-4 py-2 text-sm text-foreground/70 transition hover:bg-black/5 w-full"
|
||||
>
|
||||
돌아가기
|
||||
</Link>
|
||||
{canManagePost ? (
|
||||
<Link
|
||||
href={`/edit/${post._id}`}
|
||||
className="border border-border bg-foreground px-4 py-2 text-sm text-background transition hover:opacity-90 w-full"
|
||||
>
|
||||
편집
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<DetailRawPanel data={post} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/frontend/src/app/not-found.tsx
Normal file
15
apps/frontend/src/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<figure>
|
||||
<picture className="block bg-gray-100 rounded-xl aspect-1-1 overflow-hidden image-scale object-shadowed mb-8">
|
||||
<img src="https://api.imnya.ng/mitda/mizuki" width={200} height={200} alt="Not Found Image" />
|
||||
</picture>
|
||||
</figure>
|
||||
<h1 className="text-4xl font-bold">404</h1>
|
||||
<p>괜찮아요. 이런날도 있는거죠</p>
|
||||
<br/>
|
||||
<a href="/">← Go Home</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }) => (
|
||||
<a
|
||||
key={photo.key}
|
||||
href={photo.href}
|
||||
href={`/detail/${photo.key}`}
|
||||
onClick={onClick}
|
||||
onContextMenu={(event) => {
|
||||
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}`}
|
||||
>
|
||||
|
|
|
|||
120
apps/frontend/src/components/detail-raw-panel.tsx
Normal file
120
apps/frontend/src/components/detail-raw-panel.tsx
Normal file
|
|
@ -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 <span className="text-foreground/50">null</span>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-foreground/50">[]</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-foreground/70">[</span>
|
||||
<span className="block">
|
||||
{value.map((item, index) => (
|
||||
<span key={index} className="block">
|
||||
<span className="text-foreground/40">{indent} </span>
|
||||
{renderJsonValue(item, indentLevel + 1)}
|
||||
{index < value.length - 1 ? <span className="text-foreground/70">,</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-foreground/70">{indent}]</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <span className="text-foreground/50">{`{}`}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-foreground/70">{`{`}</span>
|
||||
<span className="block">
|
||||
{entries.map(([key, childValue], index) => (
|
||||
<span key={key} className="block">
|
||||
<span className="text-foreground/40">{indent} </span>
|
||||
<span className="text-sky-600">"{key}"</span>
|
||||
<span className="text-foreground/70">: </span>
|
||||
{renderJsonValue(childValue, indentLevel + 1)}
|
||||
{index < entries.length - 1 ? <span className="text-foreground/70">,</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-foreground/70">{indent}{"}"}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return <span className="text-emerald-700">"{value}"</span>;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return <span className="text-amber-700">{value}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return <span className="text-violet-700">{String(value)}</span>;
|
||||
}
|
||||
|
||||
return <span className="text-foreground/60">{String(value)}</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-6 border border-border bg-background/80 p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 text-left"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-foreground/40">Raw</p>
|
||||
<span className="text-xs text-foreground/50">{isOpen ? "o" : "x"}</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<pre className="mt-3 overflow-auto border border-border/70 bg-black/5 p-3 text-xs leading-5 text-foreground/80">
|
||||
{renderJsonValue(data)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Me | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const developerClickTimeoutRef = useRef<number | null>(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 (
|
||||
<header className="relative z-60 flex h-16 items-center justify-between border-b border-border bg-background/90 backdrop-blur px-6">
|
||||
<a href="/" className="text-2xl">🎀</a>
|
||||
<Link href="/" className="text-2xl" id="icon" onClick={handleIconClick}>🎀</Link>
|
||||
{me ? (
|
||||
<div className="relative flex items-center gap-4" id="menu" ref={menuRef}>
|
||||
{me.role === "admin" || me.role === "writer" ? (
|
||||
<a href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</a>
|
||||
<Link href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</Link>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue