feat: implement masonry photo gallery with filtering, context menu, and modularized dashboard management sections

This commit is contained in:
암냥 2026-05-23 23:13:14 +09:00
commit 0c1c566b3d
No known key found for this signature in database
15 changed files with 924 additions and 648 deletions

View file

@ -347,6 +347,28 @@ export default function AddPage() {
}
}
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
if ((event.key === "u" || event.key === "U") && !event.repeat) {
event.preventDefault();
const form = document.getElementById("add-post-form") as HTMLFormElement | null;
form?.requestSubmit();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<Header />
@ -369,7 +391,7 @@ export default function AddPage() {
</div>
</div>
<form className="mt-5 space-y-6" onSubmit={submit}>
<form id="add-post-form" className="mt-5 space-y-6" onSubmit={submit}>
{!loadingRole && !canManagePost && (
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
. writer admin .
@ -569,4 +591,4 @@ export default function AddPage() {
</main>
</div>
);
}
}

View file

@ -0,0 +1,35 @@
import { AuditLog } from "./types";
type AuditLogSectionProps = {
logs: AuditLog[];
};
export default function AuditLogSection({ logs }: AuditLogSectionProps) {
return (
<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>
{logs.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>
);
}

View file

@ -0,0 +1,29 @@
type DashboardHeaderProps = {
loading: boolean;
error: string | null;
success: string | null;
canAccess: boolean;
};
export default function DashboardHeader({ loading, error, success, canAccess }: DashboardHeaderProps) {
return (
<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 && !canAccess ? (
<div className="border border-red-200 bg-red-50 p-4 text-sm text-red-700">
.
</div>
) : null}
</section>
);
}

View file

@ -0,0 +1,83 @@
import { UploadItem } from "./types";
type PostManagementSectionProps = {
posts: UploadItem[];
selectedPostIds: string[];
deletingPosts: boolean;
onToggleSelectAllPosts: () => void;
onBulkDeletePosts: () => void | Promise<void>;
onTogglePost: (id: string) => void;
};
export default function PostManagementSection({
posts,
selectedPostIds,
deletingPosts,
onToggleSelectAllPosts,
onBulkDeletePosts,
onTogglePost,
}: PostManagementSectionProps) {
function handleBulkDeletePosts() {
void onBulkDeletePosts();
}
function handleTogglePost(id: string) {
onTogglePost(id);
}
return (
<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={onToggleSelectAllPosts}
>
{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={handleBulkDeletePosts}
>
{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={() => handleTogglePost(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>
);
}

View file

@ -0,0 +1,43 @@
export type Role = "admin" | "writer" | "reader";
export type Me = {
id: string;
username: string;
role: Role;
};
export type UserItem = {
id: string;
discordId: string;
username: string;
avatar?: string;
role: Role;
createdAt?: string;
};
export type UploadItem = {
_id: string;
mediaUrl: string;
author?: string;
tags?: string[];
uploadedBy?: {
username?: string;
role?: Role;
};
createdAt?: string;
};
export type AuditLog = {
_id: string;
action: string;
summary?: string;
targetType?: string;
targetId?: string;
actor?: {
username?: string;
role?: string;
};
createdAt?: string;
};
export const DEFAULT_ROLES: Role[] = ["admin", "writer", "reader"];

View file

@ -0,0 +1,170 @@
import { FormEvent } from "react";
import { DEFAULT_ROLES, Role, UserItem } from "./types";
type UserManagementSectionProps = {
users: UserItem[];
roles: Role[];
pendingRole: Record<string, Role>;
savingUserId: string | null;
creating: boolean;
newDiscordId: string;
newUsername: string;
newAvatar: string;
newRole: Role;
onCreateUser: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
onNewDiscordIdChange: (value: string) => void;
onNewUsernameChange: (value: string) => void;
onNewAvatarChange: (value: string) => void;
onNewRoleChange: (value: Role) => void;
onPendingRoleChange: (userId: string, role: Role) => void;
onUpdateRole: (user: UserItem) => void | Promise<void>;
onDeleteUser: (user: UserItem) => void | Promise<void>;
};
export default function UserManagementSection({
users,
roles,
pendingRole,
savingUserId,
creating,
newDiscordId,
newUsername,
newAvatar,
newRole,
onCreateUser,
onNewDiscordIdChange,
onNewUsernameChange,
onNewAvatarChange,
onNewRoleChange,
onPendingRoleChange,
onUpdateRole,
onDeleteUser,
}: UserManagementSectionProps) {
const availableRoles = roles.length > 0 ? roles : DEFAULT_ROLES;
function handlePendingRoleChange(userId: string, role: Role) {
onPendingRoleChange(userId, role);
}
function handleUpdateRole(user: UserItem) {
void onUpdateRole(user);
}
function handleDeleteUser(user: UserItem) {
void onDeleteUser(user);
}
return (
<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={onCreateUser}>
<div>
<label className="block text-xs text-foreground/70" htmlFor="discordId">Discord ID</label>
<input
id="discordId"
type="text"
value={newDiscordId}
onChange={(event) => onNewDiscordIdChange(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) => onNewUsernameChange(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) => onNewAvatarChange(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) => onNewRoleChange(event.target.value as Role)}
className="mt-1 w-full border border-border bg-transparent px-2 py-1.5 text-sm"
>
{availableRoles.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>
{users.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) => handlePendingRoleChange(user.id, event.target.value as Role)}
className="border border-border px-2 py-1"
>
{availableRoles.map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleUpdateRole(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>
<button
type="button"
onClick={() => handleDeleteUser(user)}
disabled={savingUserId === user.id}
className="border border-red-300 bg-red-50 px-3 py-1 text-xs text-red-700 disabled:opacity-50"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View file

@ -1,51 +1,16 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
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;
};
import AuditLogSection from "./components/audit-log-section";
import DashboardHeader from "./components/dashboard-header";
import PostManagementSection from "./components/post-management-section";
import UserManagementSection from "./components/user-management-section";
import { AuditLog, Me, Role, UploadItem, UserItem } from "./components/types";
export default function DashboardPage() {
const router = useRouter();
const [me, setMe] = useState<Me | null>(null);
const [users, setUsers] = useState<UserItem[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
@ -89,7 +54,8 @@ export default function DashboardPage() {
try {
const meResponse = await fetch("/api/auth/me", { cache: "no-store" });
if (!meResponse.ok) {
throw new Error("로그인이 필요합니다.");
router.replace("/");
return;
}
const profile = (await meResponse.json()) as Me;
@ -100,6 +66,7 @@ export default function DashboardPage() {
setRoles([]);
setPosts([]);
setAuditLogs([]);
router.replace("/");
return;
}
@ -334,231 +301,48 @@ export default function DashboardPage() {
<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>
<DashboardHeader
loading={loading}
error={error}
success={success}
canAccess={Boolean(me && isAdmin)}
/>
{!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">
<div className="flex items-center gap-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>
<button
type="button"
onClick={() => {
void deleteUser(user);
}}
disabled={savingUserId === user.id}
className="border border-red-300 bg-red-50 px-3 py-1 text-xs text-red-700 disabled:opacity-50"
>
</button>
</div>
</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>
<UserManagementSection
users={sortedUsers}
roles={roles}
pendingRole={pendingRole}
savingUserId={savingUserId}
creating={creating}
newDiscordId={newDiscordId}
newUsername={newUsername}
newAvatar={newAvatar}
newRole={newRole}
onCreateUser={createUser}
onNewDiscordIdChange={setNewDiscordId}
onNewUsernameChange={setNewUsername}
onNewAvatarChange={setNewAvatar}
onNewRoleChange={setNewRole}
onPendingRoleChange={(userId, role) => {
setPendingRole((current) => ({ ...current, [userId]: role }));
}}
onUpdateRole={updateRole}
onDeleteUser={deleteUser}
/>
) : 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>
{!loading && isAdmin ? <AuditLogSection logs={auditLogs} /> : null}
<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>
{!loading && isAdmin ? (
<PostManagementSection
posts={posts}
selectedPostIds={selectedPostIds}
deletingPosts={deletingPosts}
onToggleSelectAllPosts={toggleSelectAllPosts}
onBulkDeletePosts={bulkDeletePosts}
onTogglePost={togglePost}
/>
) : null}
</main>
</div>

View file

@ -1,72 +1,16 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MasonryPhotoAlbum } from "react-photo-album";
import Header from "../components/header";
import DeleteDialog from "../components/home/delete-dialog";
import FilterBar from "../components/home/filter-bar";
import GalleryAlbum from "../components/home/gallery-album";
import GalleryContextMenu from "../components/home/context-menu";
import LoadingGrid from "../components/home/loading-grid";
import { ContextMenuState, GalleryPhoto, Me, TagItem, Upload } from "../components/home/types";
import { buildQuery, calculateListSize, DEFAULT_LIST_SIZE } from "../components/home/utils";
import { proxyMediaUrl } from "../lib/media";
type Upload = {
_id: string;
tweetId: string;
mediaIndex: number;
mediaUrl: string;
s3Key: string;
mediaType?: "image" | "video";
thumbnailUrl?: string;
author?: string;
tweet: {
url: string;
};
};
type TagItem = {
_id: string;
name: string;
usageCount: number;
};
type GalleryPhoto = {
src: string;
width: number;
height: number;
key: string;
alt: string;
author: string;
source: string;
mediaType?: "image" | "video";
thumbnailUrl?: string;
};
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[]>([]);
const [tags, setTags] = useState<TagItem[]>([]);
@ -88,22 +32,13 @@ export default function App() {
}
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[], 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);
}
return params.toString();
}
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
function toggleTag(tagName: string) {
setSelectedTags((current) => {
@ -114,6 +49,101 @@ export default function App() {
});
}
function closeContextMenu() {
setContextMenu(null);
}
function openContextMenu(photo: GalleryPhoto, x: number, y: number) {
setContextMenu({ x, y, photo });
}
function openEdit(photo: GalleryPhoto) {
window.location.href = `/edit/${photo.key}`;
}
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 copySource(photo: GalleryPhoto) {
try {
await navigator.clipboard.writeText(photo.source);
} catch (err) {
console.error("Failed to copy link:", err);
alert("링크 복사에 실패했습니다.");
}
}
async function copyImage(photo: GalleryPhoto) {
try {
const response = await fetch(photo.src);
if (!response.ok) throw new Error("Failed to fetch image");
let blob = await response.blob();
if (blob.type !== "image/png") {
const img = new Image();
const url = URL.createObjectURL(blob);
img.src = url;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas context를 가져오지 못했습니다.");
ctx.drawImage(img, 0, 0);
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
URL.revokeObjectURL(url);
if (!pngBlob) throw new Error("PNG 변환에 실패했습니다.");
blob = pngBlob;
}
const clipboardItem = new ClipboardItem({
[blob.type]: blob,
});
await navigator.clipboard.write([clipboardItem]);
} catch (err) {
console.error("Failed to copy image:", err);
alert("이미지 복사에 실패했습니다. 브라우저 호환성 문제일 수 있습니다.");
}
}
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(() => {
let active = true;
@ -135,7 +165,7 @@ export default function App() {
}
}
loadTags();
void loadTags();
return () => {
active = false;
@ -171,10 +201,6 @@ export default function App() {
}, []);
useEffect(() => {
function closeContextMenu() {
setContextMenu(null);
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setContextMenu(null);
@ -194,85 +220,6 @@ export default function App() {
};
}, [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 {
const response = await fetch(photo.src);
if (!response.ok) throw new Error("Failed to fetch image");
let blob = await response.blob();
// 대부분의 브라우저는 클립보드 복사 시 image/png만 지원하므로,
// 형식이 다를 경우 canvas를 이용해 PNG로 변환합니다.
if (blob.type !== "image/png") {
const img = new Image();
const url = URL.createObjectURL(blob);
img.src = url;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas context를 가져오지 못했습니다.");
ctx.drawImage(img, 0, 0);
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
URL.revokeObjectURL(url);
if (!pngBlob) throw new Error("PNG 변환에 실패했습니다.");
blob = pngBlob;
}
const clipboardItem = new ClipboardItem({
[blob.type]: blob
});
await navigator.clipboard.write([clipboardItem]);
// alert("이미지가 클립보드에 복사되었습니다!");
} catch (error) {
console.error("Failed to copy image:", error);
alert("이미지 복사에 실패했습니다. 브라우저 호환성 문제일 수 있습니다.");
}
}
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);
@ -378,6 +325,7 @@ export default function App() {
setHasMore(true);
isFetchingMoreRef.current = false;
setError(null);
const query = buildQuery(1, selectedTags, listSize);
const [listResponse, totalResponse] = await Promise.all([
fetch(`/api/post/list?${query}`, { cache: "no-store" }),
@ -417,7 +365,7 @@ export default function App() {
}
}
loadUploads();
void loadUploads();
return () => {
active = false;
@ -440,7 +388,10 @@ export default function App() {
(upload) =>
new Promise<GalleryPhoto | null>((resolve) => {
const image = new Image();
const srcToLoad = (upload.mediaType === "video" && upload.thumbnailUrl) ? proxyMediaUrl(upload.thumbnailUrl) : upload.mediaUrl;
const srcToLoad = (upload.mediaType === "video" && upload.thumbnailUrl)
? proxyMediaUrl(upload.thumbnailUrl)
: upload.mediaUrl;
image.src = srcToLoad;
image.onload = () => {
resolve({
@ -477,7 +428,7 @@ export default function App() {
}
}
loadPhotoSizes();
void loadPhotoSizes();
return () => {
cancelled = true;
@ -488,232 +439,45 @@ export default function App() {
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
<div className="sticky top-0 z-50">
<Header />
<div className="border-b border-border bg-background/85 backdrop-blur px-6 py-2">
<div className="flex w-full items-center justify-between overflow-x-auto text-sm text-foreground/70">
<div id="filter" className="flex flex-nowrap items-center gap-2 h-6 overflow-x-auto overflow-y-hidden mr-4">
<button
type="button"
onClick={() => setSelectedTags([])}
className={selectedTags.length === 0 ? "text-foreground" : "text-foreground/50"}
>
all
</button>
{tags.map((tag) => (
<button
key={tag._id}
type="button"
onClick={() => toggleTag(tag.name)}
className={(selectedTags.includes(tag.name) ? "text-foreground" : "text-foreground/50") + " w-fit whitespace-nowrap"}
style={{ writingMode: "horizontal-tb" }}
>
{tag.name}
</button>
))}
</div>
<span className="shrink-0 text-xs uppercase tracking-[0.3em] text-foreground/40">
{total} items
</span>
</div>
</div>
<FilterBar
tags={tags}
selectedTags={selectedTags}
total={total}
onClearTags={() => setSelectedTags([])}
onToggleTag={toggleTag}
/>
</div>
<main className="w-full">
{error ? <p className="text-red-500">{error}</p> : null}
{loading ? (
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
className="aspect-4/5 animate-pulse bg-muted/50"
/>
))}
</div>
) : (
<div className="w-full">
<MasonryPhotoAlbum<GalleryPhoto>
photos={photos}
spacing={0}
columns={(containerWidth) => getColumnsForWidth(containerWidth)}
render={{
photo: ({ onClick }, { photo }) => (
<a
key={photo.key}
href={`/detail/${photo.key}`}
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 });
}}
className="group relative block overflow-hidden image-scale"
title={`작가 ${photo.author}`}
>
{photo.mediaType === "video" ? (
<video
src={photo.src}
poster={photo.thumbnailUrl}
autoPlay
muted
loop
playsInline
className="block w-full"
/>
) : (
<img
src={photo.src}
alt={photo.alt}
loading="lazy"
decoding="async"
className="block w-full"
/>
)}
<div className="absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
window.open(photo.source, "_blank", "noopener,noreferrer");
}}
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 pointer-events-auto"
title="원본 이미지 열기"
>
<span className="truncate">© {photo.author}</span>
</button>
</div>
</a>
),
}}
componentsProps={{
container: { className: "!w-full" },
}}
/>
</div>
)
}
</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-accent"
onClick={() => {
navigator.clipboard.writeText(contextMenu.photo.source).then(() => {
setContextMenu(null);
}).catch((err) => {
console.error("Failed to copy link:", err);
alert("링크 복사에 실패했습니다.");
});
}}
>
</button>
<div className="my-1 h-px bg-border" />
{loading ? <LoadingGrid count={12} /> : <GalleryAlbum photos={photos} onOpenContextMenu={openContextMenu} />}
</main>
<GalleryContextMenu
contextMenu={contextMenu}
canManagePost={canManagePost}
onClose={closeContextMenu}
onEdit={openEdit}
onRequestDelete={setDeleteTarget}
onCopySource={copySource}
onCopyImage={copyImage}
onSaveImage={saveImage}
/>
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
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-accent"
onClick={() => {
saveImage(contextMenu.photo);
setContextMenu(null);
}}
>
</button>
</div>
) : null}
{!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-muted/50"
/>
))}
</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-accent 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 >
{loadingMore ? <LoadingGrid count={5} keyPrefix="loading-more" /> : null}
<DeleteDialog
open={Boolean(deleteTarget)}
isDeleting={isDeleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) {
void deletePhoto(deleteTarget);
}
}}
/>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { ContextMenuState, GalleryPhoto } from "./types";
type GalleryContextMenuProps = {
contextMenu: ContextMenuState | null;
canManagePost: boolean;
onClose: () => void;
onEdit: (photo: GalleryPhoto) => void;
onRequestDelete: (photo: GalleryPhoto) => void;
onCopySource: (photo: GalleryPhoto) => Promise<void>;
onCopyImage: (photo: GalleryPhoto) => Promise<void>;
onSaveImage: (photo: GalleryPhoto) => void;
};
export default function GalleryContextMenu({
contextMenu,
canManagePost,
onClose,
onEdit,
onRequestDelete,
onCopySource,
onCopyImage,
onSaveImage,
}: GalleryContextMenuProps) {
if (!contextMenu) {
return null;
}
return (
<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={() => {
onEdit(contextMenu.photo);
onClose();
}}
>
</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={() => {
onRequestDelete(contextMenu.photo);
onClose();
}}
>
</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-accent"
onClick={() => {
void onCopySource(contextMenu.photo).finally(onClose);
}}
>
</button>
<div className="my-1 h-px bg-border" />
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => {
void onCopyImage(contextMenu.photo).finally(onClose);
}}
>
</button>
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => {
onSaveImage(contextMenu.photo);
onClose();
}}
>
</button>
</div>
);
}

View file

@ -0,0 +1,49 @@
type DeleteDialogProps = {
open: boolean;
isDeleting: boolean;
onCancel: () => void;
onConfirm: () => void;
};
export default function DeleteDialog({ open, isDeleting, onCancel, onConfirm }: DeleteDialogProps) {
if (!open) {
return null;
}
return (
<div
className="fixed inset-0 z-120 flex items-center justify-center bg-black/35 px-4"
onClick={() => {
if (!isDeleting) {
onCancel();
}
}}
>
<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-accent disabled:opacity-50"
onClick={onCancel}
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={onConfirm}
disabled={isDeleting}
>
{isDeleting ? "삭제 중..." : "삭제"}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
import { TagItem } from "./types";
type FilterBarProps = {
tags: TagItem[];
selectedTags: string[];
total: number;
onClearTags: () => void;
onToggleTag: (tagName: string) => void;
};
export default function FilterBar({ tags, selectedTags, total, onClearTags, onToggleTag }: FilterBarProps) {
return (
<div className="border-b border-border bg-background/85 px-6 py-2 backdrop-blur">
<div className="flex w-full items-center justify-between overflow-x-auto text-sm text-foreground/70">
<div id="filter" className="mr-4 flex h-6 flex-nowrap items-center gap-2 overflow-x-auto overflow-y-hidden">
<button
type="button"
onClick={onClearTags}
className={selectedTags.length === 0 ? "text-foreground" : "text-foreground/50"}
>
all
</button>
{tags.map((tag) => (
<button
key={tag._id}
type="button"
onClick={() => onToggleTag(tag.name)}
className={(selectedTags.includes(tag.name) ? "text-foreground" : "text-foreground/50") + " w-fit whitespace-nowrap"}
style={{ writingMode: "horizontal-tb" }}
>
{tag.name}
</button>
))}
</div>
<span className="shrink-0 text-xs uppercase tracking-[0.3em] text-foreground/40">
{total} items
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { MasonryPhotoAlbum } from "react-photo-album";
import { getColumnsForWidth } from "./utils";
import { GalleryPhoto } from "./types";
type GalleryAlbumProps = {
photos: GalleryPhoto[];
onOpenContextMenu: (photo: GalleryPhoto, x: number, y: number) => void;
};
export default function GalleryAlbum({ photos, onOpenContextMenu }: GalleryAlbumProps) {
return (
<div className="w-full">
<MasonryPhotoAlbum<GalleryPhoto>
photos={photos}
spacing={0}
columns={(containerWidth) => getColumnsForWidth(containerWidth)}
render={{
photo: ({ onClick }, { photo }) => (
<a
key={photo.key}
href={`/detail/${photo.key}`}
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);
onOpenContextMenu(photo, Math.max(12, safeX), Math.max(12, safeY));
}}
className="group relative block overflow-hidden image-scale"
title={`작가 ${photo.author}`}
>
{photo.mediaType === "video" ? (
<video
src={photo.src}
poster={photo.thumbnailUrl}
autoPlay
muted
loop
playsInline
className="block w-full"
/>
) : (
<img
src={photo.src}
alt={photo.alt}
loading="lazy"
decoding="async"
className="block w-full"
/>
)}
<div className="absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
window.open(photo.source, "_blank", "noopener,noreferrer");
}}
className="pointer-events-auto 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"
title="원본 이미지 열기"
>
<span className="truncate">© {photo.author}</span>
</button>
</div>
</a>
),
}}
componentsProps={{
container: { className: "!w-full" },
}}
/>
</div>
);
}

View file

@ -0,0 +1,17 @@
type LoadingGridProps = {
count: number;
keyPrefix?: string;
};
export default function LoadingGrid({ count, keyPrefix = "loading" }: LoadingGridProps) {
return (
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: count }).map((_, index) => (
<div
key={`${keyPrefix}-${index}`}
className="aspect-4/5 animate-pulse bg-muted/50"
/>
))}
</div>
);
}

View file

@ -0,0 +1,41 @@
export type Upload = {
_id: string;
tweetId: string;
mediaIndex: number;
mediaUrl: string;
s3Key: string;
mediaType?: "image" | "video";
thumbnailUrl?: string;
author?: string;
tweet: {
url: string;
};
};
export type TagItem = {
_id: string;
name: string;
usageCount: number;
};
export type GalleryPhoto = {
src: string;
width: number;
height: number;
key: string;
alt: string;
author: string;
source: string;
mediaType?: "image" | "video";
thumbnailUrl?: string;
};
export type Me = {
role: "admin" | "writer" | "reader";
};
export type ContextMenuState = {
x: number;
y: number;
photo: GalleryPhoto;
};

View file

@ -0,0 +1,32 @@
const DEFAULT_LIST_SIZE = 8;
const EXTRA_PREFETCH_ROWS = 2;
export { DEFAULT_LIST_SIZE, EXTRA_PREFETCH_ROWS };
export function getColumnsForWidth(containerWidth: number) {
if (containerWidth < 520) return 2;
if (containerWidth < 900) return 3;
if (containerWidth < 1280) return 4;
return 5;
}
export function calculateListSize(viewportWidth: number, viewportHeight: number) {
const columns = getColumnsForWidth(viewportWidth);
const columnWidth = viewportWidth / columns;
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 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);
}
return params.toString();
}