This commit is contained in:
암냥 2026-04-16 00:07:00 +09:00
commit 5207f5d431
No known key found for this signature in database
25 changed files with 2932 additions and 332 deletions

View file

@ -7,7 +7,7 @@ const nextConfig: NextConfig = {
return [
{
source: '/api/:path*',
destination: 'http://localhost:1108/:path*',
destination: `${process.env.API_BASE_URL}/:path*`,
},
];
},

View file

@ -0,0 +1,438 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import Header from "../../components/header";
type SourceType = "twitter" | "pixiv";
type PreviewItem = {
url: string;
};
type TweetApiResponse = {
tweet?: {
author?: { name?: string };
media?: { photos?: Array<{ url?: string }> };
};
};
type PixivApiResponse = {
image_proxy_urls?: string[];
author_name?: string;
tags?: string[];
};
type Me = {
role: "admin" | "writer" | "reader";
};
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) {
return text
.split(/[\n,]/)
.map((tag) => tag.trim().replace(/^#/, ""))
.filter((tag) => tag.length > 0);
}
export default function AddPage() {
const [url, setUrl] = useState("");
const [author, setAuthor] = useState("");
const [tagsText, setTagsText] = useState("");
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
const [selected, setSelected] = useState<boolean[]>([]);
const [sourceType, setSourceType] = useState<SourceType | null>(null);
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("");
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
const [loadingRole, setLoadingRole] = useState(true);
const selectedCount = useMemo(
() => selected.filter(Boolean).length,
[selected],
);
const canPreview = url.trim().length > 0 && !loadingPreview;
const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0;
const tags = useMemo(() => splitTags(tagsText), [tagsText]);
const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-";
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
useEffect(() => {
let active = true;
async function loadMe() {
try {
const response = await fetch("/api/auth/me", { cache: "no-store" });
if (!response.ok) {
if (active) setViewerRole("guest");
return;
}
const me = (await response.json()) as Me;
if (active) {
setViewerRole(me.role);
}
} catch {
if (active) {
setViewerRole("guest");
}
} finally {
if (active) {
setLoadingRole(false);
}
}
}
void loadMe();
return () => {
active = false;
};
}, []);
function resetPreview() {
setPreviewItems([]);
setSelected([]);
setSourceType(null);
setLastFetchedUrl("");
setError(null);
setSuccess(null);
}
async function fetchPreview(targetUrl?: string) {
const trimmedUrl = (targetUrl ?? url).trim();
const source = detectSource(trimmedUrl);
setError(null);
setSuccess(null);
if (!source) {
setError("지원하지 않는 URL 형식입니다. Twitter(X) 또는 Pixiv URL을 입력해 주세요.");
return;
}
setLoadingPreview(true);
setSourceType(source);
try {
if (source === "twitter") {
const response = await fetch(`/api/tweet/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
cache: "no-store",
});
if (!response.ok) {
throw new Error(`트위터 미리보기 요청 실패: ${response.status}`);
}
const data = (await response.json()) as TweetApiResponse;
const items = (data.tweet?.media?.photos ?? [])
.map((photo) => photo.url)
.filter((photoUrl): photoUrl is string => typeof photoUrl === "string" && photoUrl.length > 0)
.map((photoUrl) => ({ url: photoUrl }));
if (items.length === 0) {
throw new Error("이미지를 찾지 못했습니다.");
}
setPreviewItems(items);
setSelected(items.map(() => true));
if (!author.trim()) {
setAuthor(data.tweet?.author?.name ?? "");
}
setLastFetchedUrl(trimmedUrl);
return;
}
const response = await fetch(`/api/pixiv/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
cache: "no-store",
});
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("이미지를 찾지 못했습니다.");
}
setPreviewItems(items);
setSelected(items.map(() => true));
if (!author.trim()) {
setAuthor(data.author_name ?? "");
}
if (!tagsText.trim()) {
const pixivTags = (data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", ");
setTagsText(pixivTags);
}
setLastFetchedUrl(trimmedUrl);
} catch (fetchError) {
setPreviewItems([]);
setSelected([]);
setLastFetchedUrl("");
setError(fetchError instanceof Error ? fetchError.message : "미리보기를 불러오지 못했습니다.");
} finally {
setLoadingPreview(false);
}
}
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;
}
setSubmitting(true);
try {
const tags = splitTags(tagsText);
const response = await fetch("/api/post/upload", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: url.trim(),
author: author.trim() || undefined,
tag: tags.length > 0 ? tags : undefined,
selected,
}),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(responseText || `업로드 실패: ${response.status}`);
}
setSuccess(`${selectedCount}개 이미지 업로드를 요청했습니다.`);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다.");
} finally {
setSubmitting(false);
}
}
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">Add Post</h1>
<p className="mt-1 text-sm text-foreground/50"> URL을 !</p>
<ul className="text-sm text-foreground/50">
<li> - https://x.com, https://twitter.com (기타 FxEmbed URL)</li>
<li> - https://pixiv.net (#R-18 태그가 들어갈 시 거부)</li>
</ul>
</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">Selected: <span className="font-medium text-foreground/90">{selectedCount}/{previewItems.length}</span></p>
</div>
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
{!loadingRole && !canManagePost ? (
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
. writer admin .
</div>
) : null}
<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();
}
}}
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
/>
</div>
<p className="text-xs text-foreground/55">URL .</p>
</div>
<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>
<input
id="author"
type="text"
value={author}
onChange={(event) => setAuthor(event.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>
</div>
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="tags"></label>
<input
id="tags"
type="text"
value={tagsText}
onChange={(event) => setTagsText(event.target.value)}
placeholder=""
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
/>
<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}</p> : null}
{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>
<span>selected: {selectedCount}/{previewItems.length}</span>
<span>tags: {tags.length}</span>
</div>
{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>
<div className="flex items-center gap-2 text-xs text-foreground/60">
<span>{selectedCount} </span>
<button
type="button"
className="border border-border px-2 py-1 text-xs text-foreground/70 hover:bg-black/5"
onClick={() => setSelected(previewItems.map(() => true))}
>
</button>
<button
type="button"
className="border border-border px-2 py-1 text-xs text-foreground/70 hover:bg-black/5"
onClick={() => setSelected(previewItems.map(() => false))}
>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{previewItems.map((item, index) => (
<label
key={`${item.url}-${index}`}
className={`group relative block cursor-pointer overflow-hidden border bg-white/20 transition ${selected[index] ? "border-foreground/35" : "border-border"}`}
>
<img
src={item.url}
alt={`preview ${index + 1}`}
loading="lazy"
decoding="async"
className={`aspect-4/5 w-full object-cover transition ${selected[index] ? "opacity-100" : "opacity-35 grayscale-20"}`}
/>
<div className="absolute left-2 top-2 border border-border/60 bg-background/80 px-2 py-1 text-xs backdrop-blur">
<input
type="checkbox"
checked={selected[index] ?? false}
onChange={() => {
setSelected((current) => current.map((value, i) => (i === index ? !value : value)));
}}
/>
</div>
{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>
</label>
))}
</div>
</section>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="submit"
disabled={!canSubmit || !canManagePost || loadingRole}
className="border border-border bg-foreground px-4 py-2 text-sm text-background transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? "업로드 중..." : "업로드"}
</button>
</div>
</form>
</section>
</main>
</div>
);
}

View file

@ -0,0 +1,525 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import Header from "../../components/header";
type Role = "admin" | "writer" | "reader";
type Me = {
id: string;
username: string;
role: Role;
};
type UserItem = {
id: string;
discordId: string;
username: string;
avatar?: string;
role: Role;
createdAt?: string;
};
type UploadItem = {
_id: string;
mediaUrl: string;
author?: string;
tags?: string[];
uploadedBy?: {
username?: string;
role?: Role;
};
createdAt?: string;
};
type AuditLog = {
_id: string;
action: string;
summary?: string;
targetType?: string;
targetId?: string;
actor?: {
username?: string;
role?: string;
};
createdAt?: string;
};
export default function DashboardPage() {
const [me, setMe] = useState<Me | null>(null);
const [users, setUsers] = useState<UserItem[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [pendingRole, setPendingRole] = useState<Record<string, Role>>({});
const [posts, setPosts] = useState<UploadItem[]>([]);
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
const [deletingPosts, setDeletingPosts] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [savingUserId, setSavingUserId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [newDiscordId, setNewDiscordId] = useState("");
const [newUsername, setNewUsername] = useState("");
const [newAvatar, setNewAvatar] = useState("");
const [newRole, setNewRole] = useState<Role>("reader");
const isAdmin = me?.role === "admin";
const sortedUsers = useMemo(
() => [...users].sort((a, b) => a.username.localeCompare(b.username)),
[users],
);
function buildPostQuery(page: number, size: number) {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("size", String(size));
return params.toString();
}
async function loadAll() {
setLoading(true);
setError(null);
try {
const meResponse = await fetch("/api/auth/me", { cache: "no-store" });
if (!meResponse.ok) {
throw new Error("로그인이 필요합니다.");
}
const profile = (await meResponse.json()) as Me;
setMe(profile);
if (profile.role !== "admin") {
setUsers([]);
setRoles([]);
setPosts([]);
setAuditLogs([]);
return;
}
const [usersResponse, rolesResponse, postsResponse, logsResponse] = await Promise.all([
fetch("/api/auth/users", { cache: "no-store" }),
fetch("/api/auth/roles", { cache: "no-store" }),
fetch(`/api/post/list?${buildPostQuery(1, 80)}`, { cache: "no-store" }),
fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" }),
]);
if (!usersResponse.ok) {
throw new Error(`유저 목록을 불러오지 못했습니다: ${usersResponse.status}`);
}
if (!rolesResponse.ok) {
throw new Error(`역할 목록을 불러오지 못했습니다: ${rolesResponse.status}`);
}
if (!postsResponse.ok) {
throw new Error(`게시물 목록을 불러오지 못했습니다: ${postsResponse.status}`);
}
if (!logsResponse.ok) {
throw new Error(`감사 로그를 불러오지 못했습니다: ${logsResponse.status}`);
}
const usersData = (await usersResponse.json()) as UserItem[];
const roleData = (await rolesResponse.json()) as Role[];
const postData = (await postsResponse.json()) as UploadItem[];
const logData = (await logsResponse.json()) as AuditLog[];
setUsers(usersData);
setRoles(roleData);
setPosts(postData);
setAuditLogs(logData);
setPendingRole(
Object.fromEntries(usersData.map((user) => [user.id, user.role])) as Record<string, Role>,
);
setSelectedPostIds([]);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "대시보드를 불러오지 못했습니다.");
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadAll();
}, []);
async function updateRole(user: UserItem) {
const nextRole = pendingRole[user.id] ?? user.role;
if (nextRole === user.role) {
return;
}
setSavingUserId(user.id);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/auth/role", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: user.id,
role: nextRole,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `역할 변경 실패: ${response.status}`);
}
setUsers((current) => current.map((item) => (item.id === user.id ? { ...item, role: nextRole } : item)));
setSuccess(`${user.username} 권한을 ${nextRole}(으)로 변경했습니다.`);
void refreshLogs();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "역할 변경에 실패했습니다.");
} finally {
setSavingUserId(null);
}
}
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setCreating(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/auth/user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
discordId: newDiscordId.trim(),
username: newUsername.trim(),
avatar: newAvatar.trim() || undefined,
role: newRole,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `유저 생성 실패: ${response.status}`);
}
const created = (await response.json()) as UserItem;
setUsers((current) => [created, ...current]);
setPendingRole((current) => ({ ...current, [created.id]: created.role }));
setNewDiscordId("");
setNewUsername("");
setNewAvatar("");
setNewRole("reader");
setSuccess(`${created.username} 유저를 생성했습니다.`);
void refreshLogs();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "유저 생성에 실패했습니다.");
} finally {
setCreating(false);
}
}
async function refreshLogs() {
try {
const response = await fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" });
if (!response.ok) {
return;
}
const data = (await response.json()) as AuditLog[];
setAuditLogs(data);
} catch {
// ignore refresh error
}
}
async function bulkDeletePosts() {
if (selectedPostIds.length === 0) {
return;
}
const confirmed = window.confirm(`선택한 ${selectedPostIds.length}개 게시물을 삭제할까요?`);
if (!confirmed) {
return;
}
setDeletingPosts(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/post/bulk-delete", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
ids: selectedPostIds,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `일괄 삭제 실패: ${response.status}`);
}
const result = (await response.json()) as { deletedCount?: number };
setPosts((current) => current.filter((post) => !selectedPostIds.includes(post._id)));
setSuccess(`${result.deletedCount ?? selectedPostIds.length}개 게시물을 삭제했습니다.`);
setSelectedPostIds([]);
void refreshLogs();
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "게시물 일괄 삭제에 실패했습니다.");
} finally {
setDeletingPosts(false);
}
}
function togglePost(id: string) {
setSelectedPostIds((current) => {
if (current.includes(id)) {
return current.filter((value) => value !== id);
}
return [...current, id];
});
}
function toggleSelectAllPosts() {
if (selectedPostIds.length === posts.length) {
setSelectedPostIds([]);
return;
}
setSelectedPostIds(posts.map((post) => post._id));
}
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 space-y-8 px-4 py-8 sm:px-6 lg:px-8">
<section className="space-y-4 border-b border-border/70 pb-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl tracking-wide">Dashboard</h1>
<p className="mt-1 text-sm text-foreground/60">/ </p>
</div>
</div>
{loading ? <p className="text-sm text-foreground/60"> ...</p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
{!loading && me && !isAdmin ? (
<div className="border border-red-200 bg-red-50 p-4 text-sm text-red-700">
.
</div>
) : null}
</section>
{!loading && isAdmin ? (
<section className="space-y-4 border-b border-border/70 pb-8">
<h2 className="text-lg tracking-wide">User Management</h2>
<form className="grid gap-3 border border-border/70 p-4 sm:grid-cols-2 lg:grid-cols-4" onSubmit={createUser}>
<div>
<label className="block text-xs text-foreground/70" htmlFor="discordId">Discord ID</label>
<input
id="discordId"
type="text"
value={newDiscordId}
onChange={(event) => setNewDiscordId(event.target.value)}
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
required
/>
</div>
<div>
<label className="block text-xs text-foreground/70" htmlFor="username">Username</label>
<input
id="username"
type="text"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
required
/>
</div>
<div>
<label className="block text-xs text-foreground/70" htmlFor="avatar">Avatar (optional)</label>
<input
id="avatar"
type="text"
value={newAvatar}
onChange={(event) => setNewAvatar(event.target.value)}
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
/>
</div>
<div>
<label className="block text-xs text-foreground/70" htmlFor="role">Role</label>
<select
id="role"
value={newRole}
onChange={(event) => setNewRole(event.target.value as Role)}
className="mt-1 w-full border border-border bg-transparent px-2 py-1.5 text-sm"
>
{(roles.length > 0 ? roles : ["admin", "writer", "reader"]).map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
</div>
<div className="flex justify-end sm:col-span-2 lg:col-span-4">
<button
type="submit"
disabled={creating}
className="border border-border bg-foreground px-4 py-2 text-sm text-background disabled:opacity-60"
>
{creating ? "생성 중..." : "유저 생성"}
</button>
</div>
</form>
<div className="overflow-x-auto border border-border/70">
<table className="w-full text-left text-sm">
<thead className="bg-black/5 text-foreground/70">
<tr>
<th className="px-3 py-2">Username</th>
<th className="px-3 py-2">Discord ID</th>
<th className="px-3 py-2">Role</th>
<th className="px-3 py-2">Action</th>
</tr>
</thead>
<tbody>
{sortedUsers.map((user) => (
<tr key={user.id} className="border-t border-border/50">
<td className="px-3 py-2">{user.username}</td>
<td className="px-3 py-2 font-mono text-xs">{user.discordId}</td>
<td className="px-3 py-2">
<select
value={pendingRole[user.id] ?? user.role}
onChange={(event) =>
setPendingRole((current) => ({
...current,
[user.id]: event.target.value as Role,
}))
}
className="border border-border px-2 py-1"
>
{(roles.length > 0 ? roles : ["admin", "writer", "reader"]).map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
</td>
<td className="px-3 py-2">
<button
type="button"
onClick={() => {
void updateRole(user);
}}
disabled={savingUserId === user.id || (pendingRole[user.id] ?? user.role) === user.role}
className="border border-border px-3 py-1 disabled:opacity-50"
>
{savingUserId === user.id ? "저장 중..." : "저장"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
) : null}
{!loading && isAdmin ? (
<section className="space-y-4">
<h2 className="text-lg tracking-wide">Audit Log</h2>
<div className="overflow-x-auto border border-border/70">
<table className="w-full text-left text-sm">
<thead className="bg-black/5 text-foreground/70">
<tr>
<th className="px-3 py-2">Time</th>
<th className="px-3 py-2">Actor</th>
<th className="px-3 py-2">Action</th>
<th className="px-3 py-2">Summary</th>
</tr>
</thead>
<tbody>
{auditLogs.map((log) => (
<tr key={log._id} className="border-t border-border/50">
<td className="px-3 py-2 text-xs text-foreground/70">{log.createdAt ? new Date(log.createdAt).toLocaleString() : "-"}</td>
<td className="px-3 py-2">{log.actor?.username || "system"}</td>
<td className="px-3 py-2 font-mono text-xs">{log.action}</td>
<td className="px-3 py-2">{log.summary || `${log.targetType || "target"}:${log.targetId || "-"}`}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
) : null}
{!loading && isAdmin ? (
<section className="space-y-4 border-b border-border/70 pb-8">
<div className="sticky top-2 z-40 -mx-1 flex flex-wrap items-center justify-between gap-3 border border-border/70 bg-background/90 px-3 py-2 backdrop-blur">
<h2 className="text-lg tracking-wide">Post Management</h2>
<div className="flex items-center gap-2">
<button
type="button"
className="border border-border px-3 py-1 text-sm"
onClick={toggleSelectAllPosts}
>
{selectedPostIds.length === posts.length && posts.length > 0 ? "전체 해제" : "전체 선택"}
</button>
<button
type="button"
className="border border-red-300 bg-red-50 px-3 py-1 text-sm text-red-700 disabled:opacity-50"
disabled={selectedPostIds.length === 0 || deletingPosts}
onClick={() => {
void bulkDeletePosts();
}}
>
{deletingPosts ? "삭제 중..." : `선택 삭제 (${selectedPostIds.length})`}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{posts.map((post) => {
const checked = selectedPostIds.includes(post._id);
return (
<label
key={post._id}
className={`group relative block cursor-pointer overflow-hidden border bg-white/20 transition ${checked ? "border-red-500" : "border-border"}`}
>
<img
src={post.mediaUrl}
alt={post.author || "post"}
loading="lazy"
decoding="async"
className="aspect-4/5 w-full object-cover"
/>
<div className="absolute left-2 top-2 bg-background/90 px-2 py-1 text-xs">
<input
type="checkbox"
checked={checked}
onChange={() => togglePost(post._id)}
/>
</div>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-2 py-1.5 text-[11px] text-white">
<p className="truncate">author: {post.author || "unknown"}</p>
<p className="truncate">uploader: {post.uploadedBy?.username || "unknown"}</p>
</div>
</label>
);
})}
</div>
</section>
) : null}
</main>
</div>
);
}

View file

@ -0,0 +1,283 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import Header from "../../../components/header";
type SourceType = "twitter" | "pixiv";
type PostDetailResponse = {
_id: string;
type: SourceType;
url?: string;
author?: string;
tags?: string[];
mediaUrl?: string;
};
type Me = {
role: "admin" | "writer" | "reader";
};
function splitTags(text: string) {
return text
.split(/[\n,]/)
.map((tag) => tag.trim().replace(/^#/, ""))
.filter((tag) => tag.length > 0);
}
export default function EditPage() {
const params = useParams<{ id: string }>();
const id = params?.id;
const [sourceType, setSourceType] = useState<SourceType | null>(null);
const [url, setUrl] = useState("");
const [author, setAuthor] = useState("");
const [tagsText, setTagsText] = useState("");
const [currentImage, setCurrentImage] = useState<string | null>(null);
const [loadingPost, setLoadingPost] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
const [loadingRole, setLoadingRole] = useState(true);
const tags = useMemo(() => splitTags(tagsText), [tagsText]);
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
const canSubmit = !loadingPost && !submitting && canManagePost && !loadingRole;
const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-";
useEffect(() => {
let active = true;
async function loadMe() {
try {
const response = await fetch("/api/auth/me", { cache: "no-store" });
if (!response.ok) {
if (active) setViewerRole("guest");
return;
}
const me = (await response.json()) as Me;
if (active) {
setViewerRole(me.role);
}
} catch {
if (active) {
setViewerRole("guest");
}
} finally {
if (active) {
setLoadingRole(false);
}
}
}
void loadMe();
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!id) {
return;
}
let active = true;
async function loadPost() {
setLoadingPost(true);
setError(null);
try {
const response = await fetch(`/api/post/detail/${id}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`수정 데이터를 불러오지 못했습니다: ${response.status}`);
}
const post = (await response.json()) as PostDetailResponse;
if (!active) {
return;
}
setSourceType(post.type);
setUrl(post.url?.trim() ?? "");
setAuthor(post.author ?? "");
setTagsText((post.tags ?? []).join(", "));
setCurrentImage(post.mediaUrl ?? null);
} catch (loadError) {
if (active) {
setError(loadError instanceof Error ? loadError.message : "수정 데이터를 불러오지 못했습니다.");
}
} finally {
if (active) {
setLoadingPost(false);
}
}
}
void loadPost();
return () => {
active = false;
};
}, [id]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSuccess(null);
if (!id) {
setError("포스트 ID를 확인할 수 없습니다.");
return;
}
if (!canManagePost) {
setError("writer 또는 admin 권한이 필요합니다.");
return;
}
setSubmitting(true);
try {
const normalizedTags = splitTags(tagsText);
const response = await fetch(`/api/post/edit/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
author: author.trim() || undefined,
tag: normalizedTags.length > 0 ? normalizedTags : undefined,
}),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(responseText || `수정 실패: ${response.status}`);
}
setSuccess("게시물 정보를 수정했습니다. 프리뷰 이미지는 변경되지 않습니다.");
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "수정에 실패했습니다.");
} finally {
setSubmitting(false);
}
}
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">Edit Post</h1>
<p className="mt-1 text-sm text-foreground/50"> , / .</p>
</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">Preview: <span className="font-medium text-foreground/90">locked</span></p>
</div>
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
{!loadingRole && !canManagePost ? (
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
. writer admin .
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-sm text-foreground/80"> </p>
{currentImage ? (
<img
src={currentImage}
alt="current media"
loading="lazy"
decoding="async"
className="aspect-4/5 w-full max-w-60 border border-border object-cover"
/>
) : (
<div className="aspect-4/5 w-full max-w-60 border border-border bg-black/5" />
)}
</div>
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="url">URL ()</label>
<input
id="url"
type="url"
value={url}
onChange={(event) => setUrl(event.target.value)}
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm text-foreground/60 outline-none"
disabled
readOnly
/>
<p className="text-xs text-foreground/55"> URL .</p>
</div>
</div>
<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>
<input
id="author"
type="text"
value={author}
onChange={(event) => setAuthor(event.target.value)}
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
/>
</div>
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="tags"></label>
<input
id="tags"
type="text"
value={tagsText}
onChange={(event) => setTagsText(event.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}</p> : null}
{loadingPost ? (
<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>
<span>preview: locked</span>
<span>tags: {tags.length}</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<a
href="/"
className="border border-border px-4 py-2 text-sm text-foreground/70 transition hover:bg-black/5"
>
</a>
<button
type="submit"
disabled={!canSubmit}
className="border border-border bg-foreground px-4 py-2 text-sm text-background transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? "수정 중..." : "수정"}
</button>
</div>
</form>
</section>
</main>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 128 128"><path d="M60.23 50.47s-10.56 2.98-19.38 8.8c-7.26 4.78-13.83 12.04-13.83 12.04l1.88 17.76L34.1 93l21.81-19.63s3.1-8.1 4.21-13.65c.58-2.86.88-5.66.88-5.66zM69.19 52.69s2.21 8.35 3.02 11.66c.92 3.74 2.69 9.82 2.69 9.82l21.69 15.42 8.2-16.05s-4.61-5.72-7.09-8.71-16.57-12.72-16.57-12.72z" style="fill:#af0c1a"/><path d="M13.32 98.51s.75-6.87 6.39-16.69 8.41-11.53 8.41-11.53 3.26-.76 6.15-1.11c3.12-.38 5.72-.09 5.72-.09s-3.93 4.78-6.15 9.39-3.59 8.97-1.96 9.82c1.62.85 3.43-5 6.92-10.33 3.67-5.62 6.47-8.38 6.47-8.38s2.8.09 5.72.96c3.23.96 5.02 2.6 5.02 2.6s-5.76 15.49-8.92 24.46-7.72 20.87-8.83 21.3-3.04-3.02-4.24-5.75-5.29-17.25-5.55-16.99-15.15 2.34-15.15 2.34M74.89 74.1s2.51-1.69 4.56-2.38c2.05-.68 5.68-1.26 5.68-1.26s2.18 4.26 3.88 7.17c1.71 2.9 4.49 8.88 6.28 8.02 1.79-.85-.51-6.89-1.84-9.56-1.21-2.43-3.33-5.72-3.33-5.72s2.65-.34 7.51.6c4.87.94 7.17 2.56 7.17 2.56s4.5 6.68 6.92 10.76c3.22 5.44 7.26 14.68 7.26 14.68l-17.85 1.46-8.97 19.64s-1.24.08-1.93-.34c-.68-.43-3.91-9.3-5.66-14.5-2.75-8.21-9.68-31.13-9.68-31.13" style="fill:#ff605e"/><path d="M100.1 98.73c-.69.64-7.36 17.21-7.56 17.7-1.03 2.54-2.44 3.12-2.44 3.12s1.21 1.74 3.26 1.14c1.58-.46 9.1-18.27 9.1-18.27s13.59.13 15.57-.73c2.22-.96.92-2.77.92-2.77s-17.93-1.05-18.85-.19M13.35 98.13s4.02-.73 7.72-1.71 8.44-2.37 8.44-2.37 3.74 10.22 4.86 13.65 4.04 11.1 4.04 11.1-1.27 1.37-3.1.24c-1.71-1.06-4.98-10.1-5.84-12.5-1.1-3.04-2.53-7.55-2.53-7.55s-3.88 1.29-7.11 1.88-5.42 1.02-6.28.16c-.86-.85-.2-2.9-.2-2.9M46.36 28.97l-18.55-4.76L14.7 34.87l-5.44 26 2.97 5s3.22 2.46 12.28.44c9.06-2.03 29.52-9.91 29.52-9.91s2.61 1.08 4.95 1.4 6.33-.52 6.33-.52 5.02.82 8.01.61c2.98-.21 4.26-.53 4.26-.53s9.81 5.22 19.61 7.99 17.37 3.4 19.08 3.2c2.66-.32 3.94-4.8 3.94-4.8l-1.07-9.81-14.28-30.7-29.74 8.53-1.81 2.03-7.79-2.12-10.57 2.08-1.33-1.56z" style="fill:#dc0d28"/><path d="M54.59 33.37s1.18-3.66 10.07-3.66c8.29 0 9.41 3.28 9.41 3.28s1.08 7.2.1 13.44c-.87 5.58-3.96 11.41-3.96 11.41s-2.05.51-5.22.6c-2.99.08-6.17-.67-6.17-.67S56 52.8 54.81 46.56c-1.14-6.04-.22-13.19-.22-13.19" style="fill:#ff605e"/><path d="M59.9 45.07c1.7.34 2.67-4.23 3.74-5.48 1.68-1.95 5.66-1.57 5.59-4.18-.05-2.06-7.63-3.01-10.36.81-1.9 2.67-1.68 8.31 1.03 8.85" style="fill:#fcc4bf"/><path d="M75.11 31.77s1.13 1.38 2.13 3.86c.74 1.82 1.54 4.48 1.54 4.48s3.68-3.21 6.83-4.95c3.14-1.74 18.15-7.07 18.54.2.31 5.91-10.75 5.79-15.94 7.9-3.65 1.48-8.62 4.28-8.62 4.28v6.02s2.4 2.56 8.02 3.03 20.03.3 22.87.42c5.91.25 7.98 2.9 7.98 6.05 0 3.14-1.11 4.43-.74 4.84.48.54 2.95-1.43 4.29-6.52s2.37-14.6 1.97-22.75c-.47-9.63-2.37-22.52-6.67-24.92s-16.84.8-24.2 3.95c-12.38 5.3-18 14.11-18 14.11M50.37 39.74s.46-2.37 1.33-4.25c.68-1.47 1.93-3.28 1.93-3.28s-2.36-6.15-11.05-11.14-26.87-8.89-32.66-6.69c-2.88 1.09-5.67 8.48-6.52 19.7-.73 9.74.23 21.4 3.3 27.15 2.31 4.33 8.17 7.55 9.95 5.77.87-.87-5.77-3.89.56-7.87 3.67-2.3 10.69-2.1 16.78-2.5s8.69-.1 11.89-.9 5.19-2 5.19-2-.75-2.5-.9-3.7c-.21-1.64-.34-2.98-.34-2.98s-2.11-1.19-7.65-2.11c-5.43-.91-14.35-.71-14.48-6.69-.17-7.59 8.59-5.69 12.68-4s9.49 4.99 9.99 5.49" style="fill:#ff605e"/></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -68,7 +68,7 @@
}
.image-scale:hover {
z-index: 10;
z-index: 80;
box-shadow: 0 15px 45px #0006;
}

View file

@ -5,10 +5,7 @@ import "react-photo-album/masonry.css";
export const metadata: Metadata = {
title: "Akiyama Mizuki",
description: "Gallery",
icons: {
icon: "/favicon.svg",
},
description: "Gallery"
};
export default function RootLayout({

View file

@ -10,6 +10,7 @@ type Upload = {
mediaIndex: number;
mediaUrl: string;
s3Key: string;
author?: string;
tweet: {
url: string;
};
@ -28,9 +29,38 @@ type GalleryPhoto = {
key: string;
href: string;
alt: string;
author: string;
};
const PAGE_SIZE = 20;
type Me = {
role: "admin" | "writer" | "reader";
};
type ContextMenuState = {
x: number;
y: number;
photo: GalleryPhoto;
};
const DEFAULT_LIST_SIZE = 8;
const EXTRA_PREFETCH_ROWS = 2;
function getColumnsForWidth(containerWidth: number) {
if (containerWidth < 520) return 2;
if (containerWidth < 900) return 3;
if (containerWidth < 1280) return 4;
return 5;
}
function calculateListSize(viewportWidth: number, viewportHeight: number) {
const columns = getColumnsForWidth(viewportWidth);
const columnWidth = viewportWidth / columns;
// grid item uses aspect-4/5, so height is width * (5 / 4)
const itemHeight = columnWidth * (5 / 4);
const rowsInViewport = Math.ceil(viewportHeight / itemHeight);
const size = columns * (rowsInViewport + EXTRA_PREFETCH_ROWS);
return Math.max(DEFAULT_LIST_SIZE, size);
}
export default function App() {
const [uploads, setUploads] = useState<Upload[]>([]);
@ -43,12 +73,27 @@ export default function App() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<GalleryPhoto | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [listSize, setListSize] = useState<number>(() => {
if (typeof window === "undefined") {
return DEFAULT_LIST_SIZE;
}
return calculateListSize(window.innerWidth, window.innerHeight);
});
const sentinelRef = useRef<HTMLDivElement | null>(null);
const isFetchingMoreRef = useRef(false);
const loadMoreRef = useRef<() => Promise<void>>(async () => { });
const wasIntersectingRef = useRef(false);
function buildQuery(page: number, queryTags: string[]) {
function buildQuery(page: number, queryTags: string[], size?: number) {
const params = new URLSearchParams();
params.set("page", String(page));
if (typeof size === "number") {
params.set("size", String(size));
}
for (const tag of queryTags) {
params.append("tags", tag);
}
@ -69,7 +114,7 @@ export default function App() {
async function loadTags() {
try {
const tagsResponse = await fetch("/api/tags", { cache: "no-store" });
const tagsResponse = await fetch("/api/post/tags", { cache: "no-store" });
if (!tagsResponse.ok) {
throw new Error(`Failed to load tags: ${tagsResponse.status}`);
}
@ -92,6 +137,119 @@ export default function App() {
};
}, []);
useEffect(() => {
let active = true;
async function loadMe() {
try {
const response = await fetch("/api/auth/me", { cache: "no-store" });
if (!response.ok) {
if (active) setViewerRole("guest");
return;
}
const me = (await response.json()) as Me;
if (active) {
setViewerRole(me.role);
}
} catch {
if (active) {
setViewerRole("guest");
}
}
}
void loadMe();
return () => {
active = false;
};
}, []);
useEffect(() => {
function closeContextMenu() {
setContextMenu(null);
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setContextMenu(null);
if (!isDeleting) {
setDeleteTarget(null);
}
}
}
document.addEventListener("click", closeContextMenu);
document.addEventListener("scroll", closeContextMenu, true);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("click", closeContextMenu);
document.removeEventListener("scroll", closeContextMenu, true);
document.removeEventListener("keydown", closeOnEscape);
};
}, [isDeleting]);
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
async function deletePhoto(photo: GalleryPhoto) {
try {
setIsDeleting(true);
const response = await fetch(`/api/post/delete/${photo.key}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(`Failed to delete post: ${response.status}`);
}
setUploads((current) => current.filter((upload) => upload._id !== photo.key));
setTotal((current) => Math.max(0, current - 1));
setDeleteTarget(null);
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete post");
} finally {
setIsDeleting(false);
}
}
async function copyImage(photo: GalleryPhoto) {
try {
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
const response = await fetch(photo.src, { cache: "no-store" });
const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]);
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(photo.src);
}
} catch {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(photo.src);
}
}
}
function saveImage(photo: GalleryPhoto) {
const link = document.createElement("a");
link.href = photo.src;
link.download = `${photo.key}.jpg`;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
link.remove();
}
useEffect(() => {
function updateListSize() {
const nextSize = calculateListSize(window.innerWidth, window.innerHeight);
setListSize((current) => (current === nextSize ? current : nextSize));
}
updateListSize();
window.addEventListener("resize", updateListSize);
return () => window.removeEventListener("resize", updateListSize);
}, []);
const loadMore = useCallback(async () => {
if (isFetchingMoreRef.current || loading || loadingMore || !hasMore) {
return;
@ -102,8 +260,8 @@ export default function App() {
try {
const nextPage = page + 1;
const query = buildQuery(nextPage, selectedTags);
const response = await fetch(`/api/list?${query}`, { cache: "no-store" });
const query = buildQuery(nextPage, selectedTags, listSize);
const response = await fetch(`/api/post/list?${query}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load more gallery: ${response.status}`);
@ -120,7 +278,7 @@ export default function App() {
const existingIds = new Set(current.map((item) => item._id));
const appended = data.filter((item) => !existingIds.has(item._id));
const merged = [...current, ...appended];
setHasMore(merged.length < total && data.length >= PAGE_SIZE);
setHasMore(merged.length < total && data.length >= listSize);
return merged;
});
@ -132,7 +290,11 @@ export default function App() {
isFetchingMoreRef.current = false;
setLoadingMore(false);
}
}, [hasMore, loading, loadingMore, page, selectedTags, total]);
}, [hasMore, listSize, loading, loadingMore, page, selectedTags, total]);
useEffect(() => {
loadMoreRef.current = loadMore;
}, [loadMore]);
useEffect(() => {
if (!hasMore || loading) {
@ -146,16 +308,30 @@ export default function App() {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
void loadMore();
const entry = entries[0];
if (!entry) {
return;
}
if (entry.isIntersecting && !wasIntersectingRef.current) {
wasIntersectingRef.current = true;
void loadMoreRef.current();
return;
}
if (!entry.isIntersecting) {
wasIntersectingRef.current = false;
}
},
{ rootMargin: "800px 0px" },
{ rootMargin: "200px 0px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, loadMore, loading]);
return () => {
observer.disconnect();
wasIntersectingRef.current = false;
};
}, [hasMore, loading]);
useEffect(() => {
let active = true;
@ -168,10 +344,10 @@ export default function App() {
setHasMore(true);
isFetchingMoreRef.current = false;
setError(null);
const query = buildQuery(1, selectedTags);
const query = buildQuery(1, selectedTags, listSize);
const [listResponse, totalResponse] = await Promise.all([
fetch(`/api/list?${query}`, { cache: "no-store" }),
fetch(`/api/total?${query}`, { cache: "no-store" }),
fetch(`/api/post/list?${query}`, { cache: "no-store" }),
fetch(`/api/post/total?${buildQuery(1, selectedTags)}`, { cache: "no-store" }),
]);
if (!listResponse.ok) {
@ -212,7 +388,7 @@ export default function App() {
return () => {
active = false;
};
}, [selectedTags]);
}, [listSize, selectedTags]);
const items = useMemo(() => uploads.filter((upload) => Boolean(upload.mediaUrl)), [uploads]);
@ -239,6 +415,7 @@ export default function App() {
key: upload._id,
href: upload.tweet.url,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
});
};
image.onerror = () => {
@ -249,6 +426,7 @@ export default function App() {
key: upload._id,
href: upload.mediaUrl,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
});
};
}),
@ -312,41 +490,155 @@ export default function App() {
</div>
) : (
<div className="w-full">
<MasonryPhotoAlbum
<MasonryPhotoAlbum<GalleryPhoto>
photos={photos}
spacing={0}
columns={(containerWidth) => {
if (containerWidth < 520) return 2;
if (containerWidth < 900) return 3;
if (containerWidth < 1280) return 4;
return 5;
columns={(containerWidth) => getColumnsForWidth(containerWidth)}
render={{
photo: ({ onClick }, { photo }) => (
<a
key={photo.key}
href={photo.href}
onClick={onClick}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
const safeX = Math.min(event.clientX, window.innerWidth - 200);
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}`}
>
<img
src={photo.src}
alt={photo.alt}
loading="lazy"
decoding="async"
className="block w-full"
/>
<div className="pointer-events-none absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<div className="inline-flex max-w-full items-center rounded-t-sm rounded-b-lg bg-black/72 px-3 py-1.5 text-xs text-white backdrop-blur-sm">
<span className="truncate">© {photo.author}</span>
</div>
</div>
</a>
),
}}
componentsProps={{
container: { className: "!w-full" },
image: {
loading: "lazy",
decoding: "async",
className: "block w-full",
},
link: {
className: "block overflow-hidden image-scale",
},
}}
/>
</div>
)}
</main>
{!loading && hasMore ? <div ref={sentinelRef} className="h-1 w-full" /> : null}
{loadingMore ? (
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={`loading-more-${index}`}
className="aspect-4/5 animate-pulse bg-black/8"
/>
))}
)
}
</main >
{contextMenu ? (
<div
className="fixed z-100 min-w-44 overflow-hidden rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(event) => event.stopPropagation()}
>
{canManagePost ? (
<>
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
onClick={() => {
window.location.href = `/edit/${contextMenu.photo.key}`;
setContextMenu(null);
}}
>
</button>
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50"
onClick={() => {
setDeleteTarget(contextMenu.photo);
setContextMenu(null);
}}
>
</button>
<div className="my-1 h-px bg-border" />
</>
) : null}
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
onClick={async () => {
await copyImage(contextMenu.photo);
setContextMenu(null);
}}
>
</button>
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
onClick={() => {
saveImage(contextMenu.photo);
setContextMenu(null);
}}
>
</button>
</div>
) : null}
</div>
{!loading && hasMore ? <div ref={sentinelRef} className="h-1 w-full" /> : null}
{
loadingMore ? (
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={`loading-more-${index}`}
className="aspect-4/5 animate-pulse bg-black/8"
/>
))}
</div>
) : null
}
{deleteTarget ? (
<div
className="fixed inset-0 z-120 flex items-center justify-center bg-black/35 px-4"
onClick={() => {
if (!isDeleting) {
setDeleteTarget(null);
}
}}
>
<div
className="w-full max-w-sm rounded-xl border border-border bg-background p-5 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<p className="text-base font-semibold text-foreground"> ?</p>
<p className="mt-2 text-sm text-foreground/70"> .</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="rounded px-4 py-2 text-sm text-foreground/70 hover:bg-black/5 disabled:opacity-50"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting}
>
</button>
<button
type="button"
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
onClick={() => {
void deletePhoto(deleteTarget);
}}
disabled={isDeleting}
>
{isDeleting ? "삭제 중..." : "삭제"}
</button>
</div>
</div>
</div>
) : null}
</div >
);
}

View file

@ -1,11 +1,148 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Me = {
id: string;
discordId: string;
username: string;
avatar?: string;
role: "admin" | "writer" | "reader";
};
function getAvatarUrl(me: Me) {
if (!me.avatar) {
return `https://cdn.discordapp.com/embed/avatars/${Number(me.discordId) % 5}.png`;
}
if (me.avatar.startsWith("http://") || me.avatar.startsWith("https://")) {
return me.avatar;
}
const ext = me.avatar.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/avatars/${me.discordId}/${me.avatar}.${ext}?size=128`;
}
export default function Header() {
const [me, setMe] = useState<Me | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let active = true;
async function loadMe() {
try {
const response = await fetch("/api/auth/me", { cache: "no-store" });
if (!response.ok) {
if (response.status === 401 || response.status === 404) {
if (active) setMe(null);
return;
}
throw new Error(`Failed to load profile: ${response.status}`);
}
const profile = await response.json() as Me;
if (active) {
setMe(profile);
}
} catch {
if (active) {
setMe(null);
}
}
}
void loadMe();
return () => {
active = false;
};
}, []);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (!menuRef.current) {
return;
}
if (!menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("mousedown", handleOutsideClick);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
};
}, []);
async function logout() {
try {
await fetch("/api/auth/logout", {
method: "POST",
cache: "no-store",
});
} finally {
setMenuOpen(false);
setMe(null);
window.location.href = "/";
}
}
return (
<header className="flex h-16 items-center justify-between border-b border-border bg-background/90 backdrop-blur px-6">
<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>
<div className="flex items-center" id="menu">
<a href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</a>
<a href="/login" className="text-[16px] text-foreground/50">[ <span className="text-foreground">Login</span> ]</a>
</div>
{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>
) : null}
<button
type="button"
className="block"
title={`${me.username} (${me.role})`}
onClick={() => setMenuOpen((current) => !current)}
>
<img
src={getAvatarUrl(me)}
alt={`${me.username} profile`}
className="h-8 w-8 rounded-full border border-border/70 object-cover"
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
</button>
{menuOpen ? (
<div className="absolute right-0 top-full z-70 mt-2 min-w-40 overflow-hidden rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur">
<div className="px-3 py-2 text-xs text-foreground/60">
{me.username} ({me.role})
</div>
{me.role === "admin" ? (
<a
href="/dashboard"
className="block rounded px-3 py-2 text-sm text-foreground/80 hover:bg-black/5"
onClick={() => setMenuOpen(false)}
>
Dashboard
</a>
) : null}
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50"
onClick={() => {
void logout();
}}
>
Logout
</button>
</div>
) : null}
</div>
) : (
<a href="/api/auth/discord/login" className="text-[16px] text-foreground/50">[ <span className="text-foreground">Login</span> ]</a>
)}
</header>
);
}