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 (
|
return (
|
||||||
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
|
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
@ -369,7 +391,7 @@ export default function AddPage() {
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{!loadingRole && !canManagePost && (
|
||||||
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
|
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
|
||||||
|
|
@ -569,4 +591,4 @@ export default function AddPage() {
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import Header from "../../components/header";
|
import Header from "../../components/header";
|
||||||
|
import AuditLogSection from "./components/audit-log-section";
|
||||||
type Role = "admin" | "writer" | "reader";
|
import DashboardHeader from "./components/dashboard-header";
|
||||||
|
import PostManagementSection from "./components/post-management-section";
|
||||||
type Me = {
|
import UserManagementSection from "./components/user-management-section";
|
||||||
id: string;
|
import { AuditLog, Me, Role, UploadItem, UserItem } from "./components/types";
|
||||||
username: string;
|
|
||||||
role: Role;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserItem = {
|
|
||||||
id: string;
|
|
||||||
discordId: string;
|
|
||||||
username: string;
|
|
||||||
avatar?: string;
|
|
||||||
role: Role;
|
|
||||||
createdAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UploadItem = {
|
|
||||||
_id: string;
|
|
||||||
mediaUrl: string;
|
|
||||||
author?: string;
|
|
||||||
tags?: string[];
|
|
||||||
uploadedBy?: {
|
|
||||||
username?: string;
|
|
||||||
role?: Role;
|
|
||||||
};
|
|
||||||
createdAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AuditLog = {
|
|
||||||
_id: string;
|
|
||||||
action: string;
|
|
||||||
summary?: string;
|
|
||||||
targetType?: string;
|
|
||||||
targetId?: string;
|
|
||||||
actor?: {
|
|
||||||
username?: string;
|
|
||||||
role?: string;
|
|
||||||
};
|
|
||||||
createdAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [me, setMe] = useState<Me | null>(null);
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
|
@ -89,7 +54,8 @@ export default function DashboardPage() {
|
||||||
try {
|
try {
|
||||||
const meResponse = await fetch("/api/auth/me", { cache: "no-store" });
|
const meResponse = await fetch("/api/auth/me", { cache: "no-store" });
|
||||||
if (!meResponse.ok) {
|
if (!meResponse.ok) {
|
||||||
throw new Error("로그인이 필요합니다.");
|
router.replace("/");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = (await meResponse.json()) as Me;
|
const profile = (await meResponse.json()) as Me;
|
||||||
|
|
@ -100,6 +66,7 @@ export default function DashboardPage() {
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
setPosts([]);
|
setPosts([]);
|
||||||
setAuditLogs([]);
|
setAuditLogs([]);
|
||||||
|
router.replace("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,231 +301,48 @@ export default function DashboardPage() {
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
<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">
|
<DashboardHeader
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
loading={loading}
|
||||||
<div>
|
error={error}
|
||||||
<h1 className="text-xl tracking-wide">Dashboard</h1>
|
success={success}
|
||||||
<p className="mt-1 text-sm text-foreground/60">유저/게시물 관리 및 감사 로그</p>
|
canAccess={Boolean(me && isAdmin)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? <p className="text-sm text-foreground/60">불러오는 중...</p> : null}
|
|
||||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
|
||||||
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
|
|
||||||
|
|
||||||
{!loading && me && !isAdmin ? (
|
|
||||||
<div className="border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
|
||||||
관리자만 접근할 수 있습니다.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{!loading && isAdmin ? (
|
{!loading && isAdmin ? (
|
||||||
<section className="space-y-4 border-b border-border/70 pb-8">
|
<UserManagementSection
|
||||||
<h2 className="text-lg tracking-wide">User Management</h2>
|
users={sortedUsers}
|
||||||
<form className="grid gap-3 border border-border/70 p-4 sm:grid-cols-2 lg:grid-cols-4" onSubmit={createUser}>
|
roles={roles}
|
||||||
<div>
|
pendingRole={pendingRole}
|
||||||
<label className="block text-xs text-foreground/70" htmlFor="discordId">Discord ID</label>
|
savingUserId={savingUserId}
|
||||||
<input
|
creating={creating}
|
||||||
id="discordId"
|
newDiscordId={newDiscordId}
|
||||||
type="text"
|
newUsername={newUsername}
|
||||||
value={newDiscordId}
|
newAvatar={newAvatar}
|
||||||
onChange={(event) => setNewDiscordId(event.target.value)}
|
newRole={newRole}
|
||||||
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
|
onCreateUser={createUser}
|
||||||
required
|
onNewDiscordIdChange={setNewDiscordId}
|
||||||
/>
|
onNewUsernameChange={setNewUsername}
|
||||||
</div>
|
onNewAvatarChange={setNewAvatar}
|
||||||
<div>
|
onNewRoleChange={setNewRole}
|
||||||
<label className="block text-xs text-foreground/70" htmlFor="username">Username</label>
|
onPendingRoleChange={(userId, role) => {
|
||||||
<input
|
setPendingRole((current) => ({ ...current, [userId]: role }));
|
||||||
id="username"
|
}}
|
||||||
type="text"
|
onUpdateRole={updateRole}
|
||||||
value={newUsername}
|
onDeleteUser={deleteUser}
|
||||||
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>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!loading && isAdmin ? (
|
{!loading && isAdmin ? <AuditLogSection logs={auditLogs} /> : null}
|
||||||
<section className="space-y-4 border-b border-border/70 pb-8">
|
|
||||||
<div className="sticky top-2 z-40 -mx-1 flex flex-wrap items-center justify-between gap-3 border border-border/70 bg-background/90 px-3 py-2 backdrop-blur">
|
|
||||||
<h2 className="text-lg tracking-wide">Post Management</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="border border-border px-3 py-1 text-sm"
|
|
||||||
onClick={toggleSelectAllPosts}
|
|
||||||
>
|
|
||||||
{selectedPostIds.length === posts.length && posts.length > 0 ? "전체 해제" : "전체 선택"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="border border-red-300 bg-red-50 px-3 py-1 text-sm text-red-700 disabled:opacity-50"
|
|
||||||
disabled={selectedPostIds.length === 0 || deletingPosts}
|
|
||||||
onClick={() => {
|
|
||||||
void bulkDeletePosts();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{deletingPosts ? "삭제 중..." : `선택 삭제 (${selectedPostIds.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
{!loading && isAdmin ? (
|
||||||
{posts.map((post) => {
|
<PostManagementSection
|
||||||
const checked = selectedPostIds.includes(post._id);
|
posts={posts}
|
||||||
return (
|
selectedPostIds={selectedPostIds}
|
||||||
<label
|
deletingPosts={deletingPosts}
|
||||||
key={post._id}
|
onToggleSelectAllPosts={toggleSelectAllPosts}
|
||||||
className={`group relative block cursor-pointer overflow-hidden border bg-white/20 transition ${checked ? "border-red-500" : "border-border"}`}
|
onBulkDeletePosts={bulkDeletePosts}
|
||||||
>
|
onTogglePost={togglePost}
|
||||||
<img
|
/>
|
||||||
src={post.mediaUrl}
|
|
||||||
alt={post.author || "post"}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="aspect-4/5 w-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute left-2 top-2 bg-background/90 px-2 py-1 text-xs">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => togglePost(post._id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-2 py-1.5 text-[11px] text-white">
|
|
||||||
<p className="truncate">author: {post.author || "unknown"}</p>
|
|
||||||
<p className="truncate">uploader: {post.uploadedBy?.username || "unknown"}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { MasonryPhotoAlbum } from "react-photo-album";
|
|
||||||
import Header from "../components/header";
|
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";
|
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() {
|
export default function App() {
|
||||||
const [uploads, setUploads] = useState<Upload[]>([]);
|
const [uploads, setUploads] = useState<Upload[]>([]);
|
||||||
const [tags, setTags] = useState<TagItem[]>([]);
|
const [tags, setTags] = useState<TagItem[]>([]);
|
||||||
|
|
@ -88,22 +32,13 @@ export default function App() {
|
||||||
}
|
}
|
||||||
return calculateListSize(window.innerWidth, window.innerHeight);
|
return calculateListSize(window.innerWidth, window.innerHeight);
|
||||||
});
|
});
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isFetchingMoreRef = useRef(false);
|
const isFetchingMoreRef = useRef(false);
|
||||||
const loadMoreRef = useRef<() => Promise<void>>(async () => { });
|
const loadMoreRef = useRef<() => Promise<void>>(async () => { });
|
||||||
const wasIntersectingRef = useRef(false);
|
const wasIntersectingRef = useRef(false);
|
||||||
|
|
||||||
function buildQuery(page: number, queryTags: string[], size?: number) {
|
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTag(tagName: string) {
|
function toggleTag(tagName: string) {
|
||||||
setSelectedTags((current) => {
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
|
|
@ -135,7 +165,7 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTags();
|
void loadTags();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
|
@ -171,10 +201,6 @@ export default function App() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function closeContextMenu() {
|
|
||||||
setContextMenu(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeOnEscape(event: KeyboardEvent) {
|
function closeOnEscape(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
|
|
@ -194,85 +220,6 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}, [isDeleting]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
function updateListSize() {
|
function updateListSize() {
|
||||||
const nextSize = calculateListSize(window.innerWidth, window.innerHeight);
|
const nextSize = calculateListSize(window.innerWidth, window.innerHeight);
|
||||||
|
|
@ -378,6 +325,7 @@ export default function App() {
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
isFetchingMoreRef.current = false;
|
isFetchingMoreRef.current = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const query = buildQuery(1, selectedTags, listSize);
|
const query = buildQuery(1, selectedTags, listSize);
|
||||||
const [listResponse, totalResponse] = await Promise.all([
|
const [listResponse, totalResponse] = await Promise.all([
|
||||||
fetch(`/api/post/list?${query}`, { cache: "no-store" }),
|
fetch(`/api/post/list?${query}`, { cache: "no-store" }),
|
||||||
|
|
@ -417,7 +365,7 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadUploads();
|
void loadUploads();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
|
@ -440,7 +388,10 @@ export default function App() {
|
||||||
(upload) =>
|
(upload) =>
|
||||||
new Promise<GalleryPhoto | null>((resolve) => {
|
new Promise<GalleryPhoto | null>((resolve) => {
|
||||||
const image = new Image();
|
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.src = srcToLoad;
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
resolve({
|
resolve({
|
||||||
|
|
@ -477,7 +428,7 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPhotoSizes();
|
void loadPhotoSizes();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
|
||||||
<div className="sticky top-0 z-50">
|
<div className="sticky top-0 z-50">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="border-b border-border bg-background/85 backdrop-blur px-6 py-2">
|
<FilterBar
|
||||||
<div className="flex w-full items-center justify-between overflow-x-auto text-sm text-foreground/70">
|
tags={tags}
|
||||||
<div id="filter" className="flex flex-nowrap items-center gap-2 h-6 overflow-x-auto overflow-y-hidden mr-4">
|
selectedTags={selectedTags}
|
||||||
<button
|
total={total}
|
||||||
type="button"
|
onClearTags={() => setSelectedTags([])}
|
||||||
onClick={() => setSelectedTags([])}
|
onToggleTag={toggleTag}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="w-full">
|
<main className="w-full">
|
||||||
{error ? <p className="text-red-500">{error}</p> : null}
|
{error ? <p className="text-red-500">{error}</p> : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? <LoadingGrid count={12} /> : <GalleryAlbum photos={photos} onOpenContextMenu={openContextMenu} />}
|
||||||
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
</main>
|
||||||
{Array.from({ length: 12 }).map((_, index) => (
|
|
||||||
<div
|
<GalleryContextMenu
|
||||||
key={index}
|
contextMenu={contextMenu}
|
||||||
className="aspect-4/5 animate-pulse bg-muted/50"
|
canManagePost={canManagePost}
|
||||||
/>
|
onClose={closeContextMenu}
|
||||||
))}
|
onEdit={openEdit}
|
||||||
</div>
|
onRequestDelete={setDeleteTarget}
|
||||||
) : (
|
onCopySource={copySource}
|
||||||
<div className="w-full">
|
onCopyImage={copyImage}
|
||||||
<MasonryPhotoAlbum<GalleryPhoto>
|
onSaveImage={saveImage}
|
||||||
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" />
|
|
||||||
|
|
||||||
<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}
|
{!loading && hasMore ? <div ref={sentinelRef} className="h-1 w-full" /> : null}
|
||||||
{
|
{loadingMore ? <LoadingGrid count={5} keyPrefix="loading-more" /> : null}
|
||||||
loadingMore ? (
|
|
||||||
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<DeleteDialog
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
open={Boolean(deleteTarget)}
|
||||||
<div
|
isDeleting={isDeleting}
|
||||||
key={`loading-more-${index}`}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
className="aspect-4/5 animate-pulse bg-muted/50"
|
onConfirm={() => {
|
||||||
/>
|
if (deleteTarget) {
|
||||||
))}
|
void deletePhoto(deleteTarget);
|
||||||
</div>
|
}
|
||||||
) : null
|
}}
|
||||||
}
|
/>
|
||||||
{deleteTarget ? (
|
</div>
|
||||||
<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 >
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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