Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/components/admin/board/BoardAdminSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Skeleton } from '@/components/ui';
import { BoardCardSkeleton } from './BoardCardSkeleton';

export function BoardAdminSkeleton() {
return (
<div className="flex min-w-0 flex-col gap-400 p-700">
{/* Toolbar skeleton */}
<div className="flex flex-wrap items-center gap-300">
<Skeleton className="h-20 min-w-65 flex-1 rounded-lg" />
<Skeleton className="h-12 w-30 shrink-0 rounded-lg" />
</div>

{/* Board card skeletons */}
<div className="flex flex-col gap-400">
<div className="flex flex-col gap-400">
<BoardCardSkeleton />
<div className="border-line w-full border-t" />
<BoardCardSkeleton />
</div>

<div className="border-line w-full border-t" />

<div className="flex flex-col gap-200">
<BoardCardSkeleton />
<BoardCardSkeleton />
<BoardCardSkeleton />
</div>

<Skeleton className="h-12 rounded-md" />
</div>
</div>
);
}
11 changes: 7 additions & 4 deletions src/components/admin/board/BoardCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MegaphoneIcon } from '@/components/board';
interface BoardCardProps extends React.HTMLAttributes<HTMLDivElement> {
board: Board;
onToggleComments?: (next: boolean) => void;
commentTogglePending?: boolean;
onEdit?: () => void;
onDelete?: () => void;
draggable?: boolean;
Expand All @@ -24,14 +25,16 @@ function BoardCard({
className,
board,
onToggleComments,
commentTogglePending = false,
onEdit,
onDelete,
draggable = true,
dragHandleProps,
ref,
...props
}: BoardCardProps) {
const { name, description, visibility, postCount, commentEnabled, editable } = board;
const { name, description, visibility, postCount, commentEnabled, editable, kind } = board;
const showCommentToggle = kind !== 'ALL';
const [deleteOpen, setDeleteOpen] = useState(false);

const handleDeleteConfirm = () => {
Expand Down Expand Up @@ -90,13 +93,13 @@ function BoardCard({

{/* Comment toggle */}
<div className="desktop:flex hidden w-[88px] shrink-0 flex-col justify-center gap-100">
{commentEnabled !== null && (
{showCommentToggle && (
<>
<p className="typo-body2 text-text-alternative">댓글 허용</p>
<Switch
checked={commentEnabled}
checked={commentEnabled ?? false}
onCheckedChange={onToggleComments}
disabled={!onToggleComments}
disabled={!onToggleComments || commentTogglePending}
aria-label="댓글 허용 토글"
/>
</>
Comment thread
JIN921 marked this conversation as resolved.
Expand Down
144 changes: 77 additions & 67 deletions src/components/admin/board/BoardPageContent.tsx
Comment thread
JIN921 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ import {
} from '@dnd-kit/sortable';
import { useQueryClient } from '@tanstack/react-query';

import { Icon, Skeleton } from '@/components/ui';
import { Icon } from '@/components/ui';
import { InfoCircleIcon } from '@/assets/icons';
import { BoardCard } from '@/components/admin/board/BoardCard';
import { BoardCardSkeleton } from '@/components/admin/board/BoardCardSkeleton';
import { BoardToolbar } from '@/components/admin/board/BoardToolbar';
import { CreateBoardModal } from '@/components/admin/board/modal/CreateBoardModal';
import { EditBoardModal } from '@/components/admin/board/modal/EditBoardModal';
Expand All @@ -28,22 +27,43 @@ import { useBoardDragReorder } from '@/hooks/admin';
import { useAdminBoardsQuery } from '@/hooks/queries/admin/useAdminBoardsQuery';
import { useCreateBoardMutation } from '@/hooks/queries/admin/useCreateBoardMutation';
import { useUpdateBoardMutation } from '@/hooks/queries/admin/useUpdateBoardMutation';
import { useToggleBoardCommentMutation } from '@/hooks/queries/admin/useToggleBoardCommentMutation';
import { useDeleteBoardMutation } from '@/hooks/queries/admin/useDeleteBoardMutation';
import { useUpdateBoardOrderMutation } from '@/hooks/queries/admin/useUpdateBoardOrderMutation';
import { adminBoardQueryKeys } from '@/hooks/queries/admin/boardQueryKeys';
import { ADMIN_BOARD_ERROR, getApiErrorCode, getApiErrorMessage } from '@/lib/apis/adminBoard';
import { useClubId } from '@/stores';
import { toastError } from '@/stores/useToastStore';
import { toastError, toastSuccess } from '@/stores/useToastStore';
import { toApiPermission } from '@/utils/admin/boardMapper';
import { MAX_CUSTOM_BOARDS } from '@/constants/admin/board.constants';
import type { Board, BoardListCache } from '@/types/admin/board';
import type { Board, BoardKind, BoardListCache } from '@/types/admin/board';
import type { BoardFormData } from '@/components/admin/board/modal/constants';
import { SortableBoardCard } from './SortableBoardCard';
import { BoardAdminSkeleton } from './BoardAdminSkeleton';

function subscribeMounted() {
return () => {};
}

const FIXED_BOARD_ORDER: BoardKind[] = ['ALL', 'NOTICE'];

function compareFixedBoards(a: Board, b: Board) {
const ai = FIXED_BOARD_ORDER.indexOf(a.kind);
const bi = FIXED_BOARD_ORDER.indexOf(b.kind);
return (ai === -1 ? FIXED_BOARD_ORDER.length : ai) - (bi === -1 ? FIXED_BOARD_ORDER.length : bi);
}

function handleNameMutationError(setNameError: (msg: string) => void) {
return (err: unknown) => {
const code = getApiErrorCode(err);
if (code === ADMIN_BOARD_ERROR.DUPLICATE_NAME) {
setNameError('같은 이름의 게시판이 이미 있어요.');
} else {
toastError(getApiErrorMessage(err));
}
};
}

function BoardPageContent() {
const clubId = useClubId();
const queryClient = useQueryClient();
Expand All @@ -53,6 +73,21 @@ function BoardPageContent() {
const [createNameError, setCreateNameError] = useState<string | null>(null);
const [editingBoardId, setEditingBoardId] = useState<number | null>(null);
const [editNameError, setEditNameError] = useState<string | null>(null);
const [pendingToggleIds, setPendingToggleIds] = useState<Set<number>>(() => new Set());

const addPendingToggle = (boardId: number) =>
setPendingToggleIds((prev) => {
const next = new Set(prev);
next.add(boardId);
return next;
});

const removePendingToggle = (boardId: number) =>
setPendingToggleIds((prev) => {
const next = new Set(prev);
next.delete(boardId);
return next;
});
const mounted = useSyncExternalStore(
subscribeMounted,
() => true,
Expand All @@ -70,31 +105,26 @@ function BoardPageContent() {
const { handleDragStart, handleDragEnd } = useBoardDragReorder({ onReorder: updateBoardOrder });

const { mutate: createBoard } = useCreateBoardMutation({
onSuccess: () => setCreateModalOpen(false),
onError: (err) => {
const code = getApiErrorCode(err);
if (code === ADMIN_BOARD_ERROR.DUPLICATE_NAME) {
setCreateNameError('같은 이름의 게시판이 이미 있어요.');
} else {
toastError(getApiErrorMessage(err));
}
onSuccess: () => {
setCreateModalOpen(false);
toastSuccess('게시판이 생성되었어요.');
},
onError: handleNameMutationError(setCreateNameError),
});

const { mutate: updateBoard } = useUpdateBoardMutation({
onSuccess: () => setEditingBoardId(null),
onError: (err) => {
const code = getApiErrorCode(err);
if (code === ADMIN_BOARD_ERROR.DUPLICATE_NAME) {
setEditNameError('같은 이름의 게시판이 이미 있어요.');
} else {
toastError(getApiErrorMessage(err));
}
onSuccess: () => {
setEditingBoardId(null);
toastSuccess('게시판이 수정되었어요.');
},
onError: handleNameMutationError(setEditNameError),
});

const { mutate: deleteBoard } = useDeleteBoardMutation({
onSuccess: () => setEditingBoardId(null),
onSuccess: () => {
setEditingBoardId(null);
toastSuccess('게시판이 삭제되었어요.');
},
onError: (err) => {
const code = getApiErrorCode(err);
if (code === ADMIN_BOARD_ERROR.BOARD_NOT_FOUND) {
Expand All @@ -105,7 +135,8 @@ function BoardPageContent() {
},
});

const { mutate: toggleComment } = useUpdateBoardMutation({
const { mutate: toggleComment } = useToggleBoardCommentMutation({
onSuccess: () => toastSuccess('댓글 허용 설정을 변경했어요.'),
onError: (err) => toastError(getApiErrorMessage(err)),
});

Expand All @@ -114,36 +145,7 @@ function BoardPageContent() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);

if (isLoading || !data) {
return (
<div className="flex min-w-0 flex-col gap-400 p-700">
{/* Toolbar skeleton */}
<div className="flex flex-wrap items-center gap-300">
<Skeleton className="h-20 min-w-65 flex-1 rounded-lg" />
<Skeleton className="h-12 w-30 shrink-0 rounded-lg" />
</div>

{/* Board card skeletons */}
<div className="flex flex-col gap-400">
<div className="flex flex-col gap-400">
<BoardCardSkeleton />
<div className="border-line w-full border-t" />
<BoardCardSkeleton />
</div>

<div className="border-line w-full border-t" />

<div className="flex flex-col gap-200">
<BoardCardSkeleton />
<BoardCardSkeleton />
<BoardCardSkeleton />
</div>

<Skeleton className="h-12 rounded-md" />
</div>
</div>
);
}
if (isLoading || !data) return <BoardAdminSkeleton />;

const { boards } = data;

Expand All @@ -159,6 +161,8 @@ function BoardPageContent() {
};

const handleToggleComments = (boardId: number, next: boolean) => {
if (pendingToggleIds.has(boardId)) return;

const target = boards.find((b) => b.boardId === boardId);
if (!target) return;

Expand All @@ -169,11 +173,12 @@ function BoardPageContent() {
boards: prev.boards.map((b) => (b.boardId === boardId ? { ...b, commentEnabled: next } : b)),
}));

addPendingToggle(boardId);

toggleComment(
{
boardId,
body: {
name: target.name,
description: target.description,
commentEnabled: next,
...toApiPermission(target.visibility),
Expand All @@ -183,6 +188,7 @@ function BoardPageContent() {
onError: () => {
queryClient.setQueryData(cacheKey, snapshot);
},
onSettled: () => removePendingToggle(boardId),
},
);
};
Expand Down Expand Up @@ -225,14 +231,24 @@ function BoardPageContent() {
? boards.filter((b) => b.name.toLowerCase().includes(query))
: boards;

const fixedBoards = filteredBoards.filter((b) => !b.editable);
const fixedBoards = filteredBoards.filter((b) => !b.editable).sort(compareFixedBoards);
const customBoards = filteredBoards.filter((b) => b.editable);
const totalCustomCount = boards.filter((b) => b.editable).length;
const reachedLimit = totalCustomCount >= MAX_CUSTOM_BOARDS;

const editingBoard =
editingBoardId !== null ? (boards.find((b) => b.boardId === editingBoardId) ?? null) : null;

const getToggleProps = (board: Board) => ({
onToggleComments: (next: boolean) => handleToggleComments(board.boardId, next),
commentTogglePending: pendingToggleIds.has(board.boardId),
});

const getEditableProps = (board: Board) => ({
onEdit: () => setEditingBoardId(board.boardId),
onDelete: () => handleMoveToTrash(board),
});

return (
<div className="flex min-w-0 flex-col gap-400 p-700">
<BoardToolbar
Expand All @@ -243,7 +259,7 @@ function BoardPageContent() {
// onTrashClick={() => setTrashModalOpen(true)}
onCreateClick={
reachedLimit
? () => toastError(`추가 게시판은 최대 ${MAX_CUSTOM_BOARDS}개까지 만들 수 있어요.`)
? () => toastError(`게시판은 최대 ${MAX_CUSTOM_BOARDS}개까지 만들 수 있어요.`)
: () => setCreateModalOpen(true)
}
/>
Expand All @@ -254,11 +270,7 @@ function BoardPageContent() {
{fixedBoards.map((board, index) => (
<Fragment key={board.boardId}>
Comment thread
JIN921 marked this conversation as resolved.
{index > 0 && <div className="border-line w-full border-t" />}
<BoardCard
board={board}
draggable={false}
onToggleComments={(next) => handleToggleComments(board.boardId, next)}
/>
<BoardCard board={board} draggable={false} {...getToggleProps(board)} />
</Fragment>
))}
</div>
Expand All @@ -276,9 +288,8 @@ function BoardPageContent() {
key={board.boardId}
board={board}
draggable={false}
onToggleComments={(next) => handleToggleComments(board.boardId, next)}
onEdit={() => setEditingBoardId(board.boardId)}
onDelete={() => handleMoveToTrash(board)}
{...getToggleProps(board)}
{...getEditableProps(board)}
/>
))}
</div>
Expand All @@ -298,9 +309,8 @@ function BoardPageContent() {
<SortableBoardCard
key={board.boardId}
board={board}
onToggleComments={(next) => handleToggleComments(board.boardId, next)}
onEdit={() => setEditingBoardId(board.boardId)}
onDelete={() => handleMoveToTrash(board)}
{...getToggleProps(board)}
{...getEditableProps(board)}
/>
))}
</div>
Expand All @@ -311,7 +321,7 @@ function BoardPageContent() {
<div className="bg-container-neutral-alternative flex h-12 items-center gap-200 rounded-md p-300">
<Icon src={InfoCircleIcon} size={20} className="text-icon-alternative" />
<p className="typo-body2 text-text-alternative min-w-0 flex-1">
추가 게시판은 최대 {MAX_CUSTOM_BOARDS}개입니다.
게시판 추가는 최대 {MAX_CUSTOM_BOARDS}개까지 가능 합니다.
Comment on lines 323 to +324
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

안내 문구 띄어쓰기를 다듬어 주세요.

가능 합니다가능합니다로 붙여 쓰는 편이 자연스럽습니다. PR에서 문구를 정리하는 흐름이라 여기만 남으면 눈에 띕니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/admin/board/BoardPageContent.tsx` around lines 323 - 324, Fix
the spacing in the user-facing sentence inside BoardPageContent.tsx: change the
string "게시판 추가는 최대 {MAX_CUSTOM_BOARDS}개까지 가능 합니다." to use the correct combined
form "가능합니다" so it reads "게시판 추가는 최대 {MAX_CUSTOM_BOARDS}개까지 가능합니다."; update the
JSX where MAX_CUSTOM_BOARDS is rendered (inside the <p className="typo-body2
text-text-alternative min-w-0 flex-1">) to replace the spaced "가능 합니다" with
"가능합니다".

</p>
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/components/admin/board/SortableBoardCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { BoardCard } from './BoardCard';
interface SortableBoardCardProps {
board: Board;
onToggleComments: (next: boolean) => void;
commentTogglePending?: boolean;
onEdit: () => void;
onDelete: () => void;
}

export function SortableBoardCard({
board,
onToggleComments,
commentTogglePending,
onEdit,
onDelete,
}: SortableBoardCardProps) {
Expand All @@ -25,6 +27,7 @@ export function SortableBoardCard({
ref={setNodeRef}
board={board}
onToggleComments={onToggleComments}
commentTogglePending={commentTogglePending}
onEdit={onEdit}
onDelete={onDelete}
dragHandleProps={{ ...attributes, ...listeners }}
Expand Down
4 changes: 3 additions & 1 deletion src/components/admin/member/CardinalPillList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import { MoreVerticalIcon } from '@/assets/icons';
import { AddCardinalButton, AddCardinalModal, CardinalCard } from '@/components/admin';
import { AddCardinalButton } from './AddCardinalButton';
import { AddCardinalModal } from './modal/AddCardinalModal';
import { CardinalCard } from './CardinalCard';
import {
DropdownMenu,
DropdownMenuContent,
Expand Down
Loading
Loading