diff --git a/apps/frontend/src/app/add/page.tsx b/apps/frontend/src/app/add/page.tsx index 26dc725..77617e4 100644 --- a/apps/frontend/src/app/add/page.tsx +++ b/apps/frontend/src/app/add/page.tsx @@ -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 (
@@ -369,7 +391,7 @@ export default function AddPage() {
-
+ {!loadingRole && !canManagePost && (
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다. @@ -569,4 +591,4 @@ export default function AddPage() {
); -} \ No newline at end of file +} diff --git a/apps/frontend/src/app/dashboard/components/audit-log-section.tsx b/apps/frontend/src/app/dashboard/components/audit-log-section.tsx new file mode 100644 index 0000000..850acab --- /dev/null +++ b/apps/frontend/src/app/dashboard/components/audit-log-section.tsx @@ -0,0 +1,35 @@ +import { AuditLog } from "./types"; + +type AuditLogSectionProps = { + logs: AuditLog[]; +}; + +export default function AuditLogSection({ logs }: AuditLogSectionProps) { + return ( +
+

Audit Log

+
+ + + + + + + + + + + {logs.map((log) => ( + + + + + + + ))} + +
TimeActorActionSummary
{log.createdAt ? new Date(log.createdAt).toLocaleString() : "-"}{log.actor?.username || "system"}{log.action}{log.summary || `${log.targetType || "target"}:${log.targetId || "-"}`}
+
+
+ ); +} diff --git a/apps/frontend/src/app/dashboard/components/dashboard-header.tsx b/apps/frontend/src/app/dashboard/components/dashboard-header.tsx new file mode 100644 index 0000000..6089ddc --- /dev/null +++ b/apps/frontend/src/app/dashboard/components/dashboard-header.tsx @@ -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 ( +
+
+
+

Dashboard

+

유저/게시물 관리 및 감사 로그

+
+
+ + {loading ?

불러오는 중...

: null} + {error ?

{error}

: null} + {success ?

{success}

: null} + + {!loading && !canAccess ? ( +
+ 관리자만 접근할 수 있습니다. +
+ ) : null} +
+ ); +} diff --git a/apps/frontend/src/app/dashboard/components/post-management-section.tsx b/apps/frontend/src/app/dashboard/components/post-management-section.tsx new file mode 100644 index 0000000..8fcd3cd --- /dev/null +++ b/apps/frontend/src/app/dashboard/components/post-management-section.tsx @@ -0,0 +1,83 @@ +import { UploadItem } from "./types"; + +type PostManagementSectionProps = { + posts: UploadItem[]; + selectedPostIds: string[]; + deletingPosts: boolean; + onToggleSelectAllPosts: () => void; + onBulkDeletePosts: () => void | Promise; + 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 ( +
+
+

Post Management

+
+ + +
+
+ +
+ {posts.map((post) => { + const checked = selectedPostIds.includes(post._id); + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/frontend/src/app/dashboard/components/types.ts b/apps/frontend/src/app/dashboard/components/types.ts new file mode 100644 index 0000000..2052174 --- /dev/null +++ b/apps/frontend/src/app/dashboard/components/types.ts @@ -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"]; diff --git a/apps/frontend/src/app/dashboard/components/user-management-section.tsx b/apps/frontend/src/app/dashboard/components/user-management-section.tsx new file mode 100644 index 0000000..8b6c8ba --- /dev/null +++ b/apps/frontend/src/app/dashboard/components/user-management-section.tsx @@ -0,0 +1,170 @@ +import { FormEvent } from "react"; +import { DEFAULT_ROLES, Role, UserItem } from "./types"; + +type UserManagementSectionProps = { + users: UserItem[]; + roles: Role[]; + pendingRole: Record; + savingUserId: string | null; + creating: boolean; + newDiscordId: string; + newUsername: string; + newAvatar: string; + newRole: Role; + onCreateUser: (event: FormEvent) => void | Promise; + 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; + onDeleteUser: (user: UserItem) => void | Promise; +}; + +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 ( +
+

User Management

+ +
+ + 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 + /> +
+
+ + 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 + /> +
+
+ + onNewAvatarChange(event.target.value)} + className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none" + /> +
+
+ + +
+
+ +
+ + +
+ + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
UsernameDiscord IDRoleAction
{user.username}{user.discordId} + + +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx index c8b1031..ca83484 100644 --- a/apps/frontend/src/app/dashboard/page.tsx +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -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(null); const [users, setUsers] = useState([]); const [roles, setRoles] = useState([]); @@ -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() {
-
-
-
-

Dashboard

-

유저/게시물 관리 및 감사 로그

-
-
- - {loading ?

불러오는 중...

: null} - {error ?

{error}

: null} - {success ?

{success}

: null} - - {!loading && me && !isAdmin ? ( -
- 관리자만 접근할 수 있습니다. -
- ) : null} -
+ {!loading && isAdmin ? ( -
-

User Management

-
-
- - 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 - /> -
-
- - 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 - /> -
-
- - setNewAvatar(event.target.value)} - className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none" - /> -
-
- - -
-
- -
-
- -
- - - - - - - - - - - {sortedUsers.map((user) => ( - - - - - - - ))} - -
UsernameDiscord IDRoleAction
{user.username}{user.discordId} - - -
- - -
-
-
-
- ) : null} - {!loading && isAdmin ? ( -
-

Audit Log

-
- - - - - - - - - - - {auditLogs.map((log) => ( - - - - - - - ))} - -
TimeActorActionSummary
{log.createdAt ? new Date(log.createdAt).toLocaleString() : "-"}{log.actor?.username || "system"}{log.action}{log.summary || `${log.targetType || "target"}:${log.targetId || "-"}`}
-
-
+ { + setPendingRole((current) => ({ ...current, [userId]: role })); + }} + onUpdateRole={updateRole} + onDeleteUser={deleteUser} + /> ) : null} - {!loading && isAdmin ? ( -
-
-

Post Management

-
- - -
-
+ {!loading && isAdmin ? : null} -
- {posts.map((post) => { - const checked = selectedPostIds.includes(post._id); - return ( - - ); - })} -
-
+ {!loading && isAdmin ? ( + ) : null}
diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index e5f8937..a6c7a82 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -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([]); const [tags, setTags] = useState([]); @@ -88,22 +32,13 @@ export default function App() { } return calculateListSize(window.innerWidth, window.innerHeight); }); + const sentinelRef = useRef(null); const isFetchingMoreRef = useRef(false); const loadMoreRef = useRef<() => Promise>(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((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((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((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() {
-
-
-
- - {tags.map((tag) => ( - - ))} -
- - {total} items - -
-
+ setSelectedTags([])} + onToggleTag={toggleTag} + />
+
{error ?

{error}

: null} - {loading ? ( -
- {Array.from({ length: 12 }).map((_, index) => ( -
- ))} -
- ) : ( -
- - photos={photos} - spacing={0} - columns={(containerWidth) => getColumnsForWidth(containerWidth)} - render={{ - photo: ({ onClick }, { photo }) => ( - { - 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" ? ( - - ), - }} - componentsProps={{ - container: { className: "!w-full" }, - }} - /> -
- ) - } -
- {contextMenu ? ( -
event.stopPropagation()} - > - {canManagePost ? ( - <> - - -
- - ) : null} - -
+ {loading ? : } + + + - - -
- ) : null} {!loading && hasMore ?
: null} - { - loadingMore ? ( -
- {Array.from({ length: 5 }).map((_, index) => ( -
- ))} -
- ) : null - } - {deleteTarget ? ( -
{ - if (!isDeleting) { - setDeleteTarget(null); - } - }} - > -
event.stopPropagation()} - > -

게시물을 삭제할까요?

-

삭제 후에는 되돌릴 수 없습니다.

-
- - -
-
-
- ) : null} -
+ {loadingMore ? : null} + + setDeleteTarget(null)} + onConfirm={() => { + if (deleteTarget) { + void deletePhoto(deleteTarget); + } + }} + /> +
); } diff --git a/apps/frontend/src/components/home/context-menu.tsx b/apps/frontend/src/components/home/context-menu.tsx new file mode 100644 index 0000000..5ba4ae3 --- /dev/null +++ b/apps/frontend/src/components/home/context-menu.tsx @@ -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; + onCopyImage: (photo: GalleryPhoto) => Promise; + onSaveImage: (photo: GalleryPhoto) => void; +}; + +export default function GalleryContextMenu({ + contextMenu, + canManagePost, + onClose, + onEdit, + onRequestDelete, + onCopySource, + onCopyImage, + onSaveImage, +}: GalleryContextMenuProps) { + if (!contextMenu) { + return null; + } + + return ( +
event.stopPropagation()} + > + {canManagePost ? ( + <> + + +
+ + ) : null} + +
+ + + +
+ ); +} diff --git a/apps/frontend/src/components/home/delete-dialog.tsx b/apps/frontend/src/components/home/delete-dialog.tsx new file mode 100644 index 0000000..ed921c5 --- /dev/null +++ b/apps/frontend/src/components/home/delete-dialog.tsx @@ -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 ( +
{ + if (!isDeleting) { + onCancel(); + } + }} + > +
event.stopPropagation()} + > +

게시물을 삭제할까요?

+

삭제 후에는 되돌릴 수 없습니다.

+
+ + +
+
+
+ ); +} diff --git a/apps/frontend/src/components/home/filter-bar.tsx b/apps/frontend/src/components/home/filter-bar.tsx new file mode 100644 index 0000000..9c8756e --- /dev/null +++ b/apps/frontend/src/components/home/filter-bar.tsx @@ -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 ( +
+
+
+ + {tags.map((tag) => ( + + ))} +
+ + {total} items + +
+
+ ); +} diff --git a/apps/frontend/src/components/home/gallery-album.tsx b/apps/frontend/src/components/home/gallery-album.tsx new file mode 100644 index 0000000..11fc678 --- /dev/null +++ b/apps/frontend/src/components/home/gallery-album.tsx @@ -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 ( +
+ + photos={photos} + spacing={0} + columns={(containerWidth) => getColumnsForWidth(containerWidth)} + render={{ + photo: ({ onClick }, { photo }) => ( + { + 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" ? ( + + ), + }} + componentsProps={{ + container: { className: "!w-full" }, + }} + /> +
+ ); +} diff --git a/apps/frontend/src/components/home/loading-grid.tsx b/apps/frontend/src/components/home/loading-grid.tsx new file mode 100644 index 0000000..ca7e484 --- /dev/null +++ b/apps/frontend/src/components/home/loading-grid.tsx @@ -0,0 +1,17 @@ +type LoadingGridProps = { + count: number; + keyPrefix?: string; +}; + +export default function LoadingGrid({ count, keyPrefix = "loading" }: LoadingGridProps) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+ ))} +
+ ); +} diff --git a/apps/frontend/src/components/home/types.ts b/apps/frontend/src/components/home/types.ts new file mode 100644 index 0000000..f07e303 --- /dev/null +++ b/apps/frontend/src/components/home/types.ts @@ -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; +}; diff --git a/apps/frontend/src/components/home/utils.ts b/apps/frontend/src/components/home/utils.ts new file mode 100644 index 0000000..e3f81bc --- /dev/null +++ b/apps/frontend/src/components/home/utils.ts @@ -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(); +}