feat: implement masonry photo gallery with filtering, context menu, and modularized dashboard management sections
This commit is contained in:
parent
89c831cba4
commit
0c1c566b3d
15 changed files with 924 additions and 648 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
43
apps/frontend/src/app/dashboard/components/types.ts
Normal file
43
apps/frontend/src/app/dashboard/components/types.ts
Normal 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"];
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
91
apps/frontend/src/components/home/context-menu.tsx
Normal file
91
apps/frontend/src/components/home/context-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/frontend/src/components/home/delete-dialog.tsx
Normal file
49
apps/frontend/src/components/home/delete-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/frontend/src/components/home/filter-bar.tsx
Normal file
41
apps/frontend/src/components/home/filter-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/frontend/src/components/home/gallery-album.tsx
Normal file
75
apps/frontend/src/components/home/gallery-album.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/components/home/loading-grid.tsx
Normal file
17
apps/frontend/src/components/home/loading-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/frontend/src/components/home/types.ts
Normal file
41
apps/frontend/src/components/home/types.ts
Normal 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;
|
||||
};
|
||||
32
apps/frontend/src/components/home/utils.ts
Normal file
32
apps/frontend/src/components/home/utils.ts
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue