diff --git a/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/Edit.tsx b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/Edit.tsx index 62fc1d7..9831f68 100644 --- a/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/Edit.tsx +++ b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/Edit.tsx @@ -1,30 +1,35 @@ 'use client'; -import React, { RefObject } from 'react'; import { useScroll } from '@web/hooks'; import * as style from './pageStyle.css'; import { NavBar, MainBreadcrumbItem } from '@web/components/common'; -import { Breadcrumb, Button, Chip, Icon, Accordion } from '@repo/ui'; +import { Breadcrumb, Button, Icon } from '@repo/ui'; import { POST_STATUS } from '@web/types/post'; -import { INITIAL_CONTENT_ITEMS } from './constants'; -import { DndController, useDndController } from '@web/components/common'; +import { DndController } from '@web/components/common'; import { EditPageParams } from './types'; -import { DragGuide } from './_components/DragGuide/DragGuide'; +import { useGetAllPostsQuery } from '@web/store/query/useGetAllPostsQuery'; +import { useUpdatePostsMutation } from '@web/store/mutation/useUpdatePostsMutation'; +import { useRouter } from 'next/navigation'; +import { EditContent } from './_components/EditContent/EditContent'; -type EditContentProps = { - scrollRef: RefObject; - isScrolled: boolean; - agentId: EditPageParams['agentId']; - postGroupId: EditPageParams['postGroupId']; -}; +export default function Edit({ agentId, postGroupId }: EditPageParams) { + const [scrollRef, isScrolled] = useScroll({ threshold: 100 }); + const { data: posts } = useGetAllPostsQuery({ + agentId, + postGroupId, + }); + const { mutate: updatePosts } = useUpdatePostsMutation({ + agentId, + postGroupId, + }); + const router = useRouter(); -function EditContent({ - scrollRef, - isScrolled, - agentId, - postGroupId, -}: EditContentProps) { - const { getItemsByStatus, handleRemove } = useDndController(); + /** + * READY_TO_UPLOAD 상태인 게시물이 있는지 확인 + */ + const hasReadyToUploadPosts = posts.data.posts.some( + (post) => post.status === POST_STATUS.READY_TO_UPLOAD + ); return (
@@ -41,9 +46,10 @@ function EditContent({ size="large" variant="primary" leftAddon={} - onClick={() => {}} - disabled={false} - isLoading={false} + onClick={() => + router.push(`/edit/${agentId}/${postGroupId}/schedule`) + } + disabled={!hasReadyToUploadPosts} className={style.submitButtonStyle} > 예약하러 가기 @@ -51,148 +57,23 @@ function EditContent({ } isScrolled={isScrolled} /> -
- - {/* 생성된 글 영역 */} - - - 생성된 글 - - - - item.id - )} - > - {getItemsByStatus(POST_STATUS.GENERATED).map((item) => ( - handleRemove(item.id)} - onModify={() => {}} - /> - ))} - - - - - - {/* 수정 중인 글 영역 */} - - - 수정 중인 글 - - - - item.id - )} - > - {getItemsByStatus(POST_STATUS.EDITING).length > 0 ? ( - getItemsByStatus(POST_STATUS.EDITING).map((item) => ( - handleRemove(item.id)} - onModify={() => {}} - /> - )) - ) : ( - - )} - - - - - - {/* 업로드할 글 영역 */} - - - 업로드할 글 - - - - item.id - )} - > - {getItemsByStatus(POST_STATUS.READY_TO_UPLOAD).length > 0 ? ( - getItemsByStatus(POST_STATUS.READY_TO_UPLOAD).map( - (item) => ( - handleRemove(item.id)} - onModify={() => {}} - /> - ) - ) - ) : ( - - )} - - - - - -
+ p.id).join(',')} + initialItems={posts.data.posts} + onDragEnd={(updatedItems) => { + const updatePayload = { + posts: updatedItems.map((item) => ({ + postId: item.id, + status: item.status, + displayOrder: item.displayOrder, + uploadTime: item.uploadTime, + })), + }; + updatePosts(updatePayload); + }} + > + +
); } - -export default function Edit({ agentId, postGroupId }: EditPageParams) { - const [scrollRef, isScrolled] = useScroll({ threshold: 100 }); - - return ( - { - console.log('=== Current Items Status ==='); - const itemsByStatus = { - GENERATED: items.filter((item) => item.status === 'GENERATED'), - EDITING: items.filter((item) => item.status === 'EDITING'), - READY_TO_UPLOAD: items.filter( - (item) => item.status === 'READY_TO_UPLOAD' - ), - }; - console.log('GENERATED:', itemsByStatus.GENERATED); - console.log('EDITING:', itemsByStatus.EDITING); - console.log('READY_TO_UPLOAD:', itemsByStatus.READY_TO_UPLOAD); - console.log('========================'); - }} - > - - - ); -} diff --git a/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/_components/EditContent/EditContent.css.ts b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/_components/EditContent/EditContent.css.ts new file mode 100644 index 0000000..b8cebbb --- /dev/null +++ b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/_components/EditContent/EditContent.css.ts @@ -0,0 +1,43 @@ +import { style } from '@vanilla-extract/css'; +import { vars } from '@repo/theme'; + +export const contentStyle = style({ + position: 'relative', + width: '100%', + padding: `${vars.space[80]} ${vars.space[24]}`, + margin: '0 auto', + overflowX: 'auto', +}); + +export const submitButtonStyle = style({ + fontSize: vars.typography.fontSize[18], +}); + +export const accordionStyle = style({ + display: 'flex', + flexDirection: 'row', + gap: vars.space[64], + height: 'fit-content', + minWidth: 'min-content', + padding: `0 ${vars.space[32]}`, +}); + +export const accordionTriggerStyle = style({ + height: '8rem', + padding: `${vars.space[12]} ${vars.space[16]}`, +}); + +export const accordionItemStyle = style({ + width: '51.2rem', + flex: '0 0 auto', +}); + +export const contentInnerWrapper = style({ + height: '100%', +}); + +export const buttonWrapperStyle = style({ + display: 'flex', + justifyContent: 'flex-end', + marginTop: vars.space[10], +}); diff --git a/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/_components/EditContent/EditContent.tsx b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/_components/EditContent/EditContent.tsx new file mode 100644 index 0000000..a87db05 --- /dev/null +++ b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/_components/EditContent/EditContent.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { Button, Chip, Icon, Accordion, Modal, TextField } from '@repo/ui'; +import { Post, POST_STATUS } from '@web/types/post'; +import { DndController, useDndController } from '@web/components/common'; +import { useCreateMorePostsMutation } from '@web/store/mutation/useCreateMorePostsMutation'; +import { useDeletePostMutation } from '@web/store/mutation/useDeletePostMutation'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@repo/ui/hooks'; +import { FormProvider, useForm } from 'react-hook-form'; +import { EditPageParams } from '../../types'; +import * as style from './EditContent.css'; +import { DragGuide } from '../DragGuide/DragGuide'; +import { + UpdatePromptRequest, + useUpdatePromptMutation, +} from '@web/store/mutation/useUpdatePromptMutation'; +import { useMemo } from 'react'; + +type PromptForm = UpdatePromptRequest; + +export function EditContent({ agentId, postGroupId }: EditPageParams) { + const modal = useModal(); + const { getItemsByStatus } = useDndController(); + const isExistEditingPost = getItemsByStatus(POST_STATUS.EDITING).length > 0; + const methods = useForm({ + defaultValues: { + prompt: '', + }, + }); + const { register, handleSubmit, watch, setValue } = methods; + const promptValue = watch('prompt'); + + const { mutate: createMorePosts, isPending: isCreateMorePostsPending } = + useCreateMorePostsMutation({ + agentId, + postGroupId, + }); + + const { mutate: updatePrompt, isPending: isUpdatePromptPending } = + useUpdatePromptMutation({ + agentId, + postGroupId, + }); + + const { mutate: deletePost } = useDeletePostMutation({ + agentId, + postGroupId, + }); + + const router = useRouter(); + + const handleModify = (postId: Post['id']) => { + router.push(`/edit/${agentId}/${postGroupId}/detail?postId=${postId}`); + }; + + const handleDeletePost = (postId: Post['id']) => { + modal.confirm({ + title: '정말 삭제하시겠어요?', + description: '삭제된 글은 복구할 수 없어요', + icon: , + confirmButton: '삭제하기', + cancelButton: '취소', + confirmButtonProps: { + onClick: () => { + deletePost(postId); + }, + }, + }); + }; + + const onSubmit = (data: PromptForm) => { + const editingPostIds = getItemsByStatus(POST_STATUS.EDITING).map( + (item) => item.id + ); + + updatePrompt({ + prompt: data.prompt, + postsId: editingPostIds, + }); + setValue('prompt', ''); + }; + + const skeletonData = Array.from({ length: 5 }).map((_, index) => ({ + id: 10000 + index, + summary: '', + updatedAt: '', + uploadTime: '', + isLoading: true, + })); + + /** + * 스켈레톤을 추가하기 위해 생성된 글 데이터를 가져와 로딩 상태일 때 스켈레톤 데이터를 붙인다. + */ + const data = useMemo(() => { + if (isCreateMorePostsPending) { + return [...getItemsByStatus(POST_STATUS.GENERATED), ...skeletonData]; + } + return getItemsByStatus(POST_STATUS.GENERATED); + }, [isCreateMorePostsPending, getItemsByStatus, skeletonData]); + + return ( +
+ + {/* 생성된 글 영역 */} + + + 생성된 글 + + +
+ + item.id)}> + {data.map((item) => ( + handleDeletePost(item.id)} + onModify={() => handleModify(item.id)} + isLoading={item?.isLoading ?? false} + /> + ))} + + +
+
+ +
+
+
+ + {/* 수정 중인 글 영역 */} + + + 수정 중인 글 + + + + (a.displayOrder || 0) - (b.displayOrder || 0)) + .map((item) => item.id)} + > + {isExistEditingPost && ( + +
+ + setValue('prompt', e.target.value)} + placeholder="AI에게 요청하여 글 업그레이드하기" + sumbitButton={ + + } + maxLength={5000} + /> + +
+
+ )} + {isExistEditingPost ? ( + getItemsByStatus(POST_STATUS.EDITING).map((item) => ( + handleDeletePost(item.id)} + onModify={() => handleModify(item.id)} + isLoading={isUpdatePromptPending} + /> + )) + ) : ( + + )} +
+
+
+
+ + {/* 업로드할 글 영역 */} + + + 업로드할 글 + + + + item.id + )} + > + {getItemsByStatus(POST_STATUS.READY_TO_UPLOAD).length > 0 ? ( + getItemsByStatus(POST_STATUS.READY_TO_UPLOAD).map((item) => ( + handleDeletePost(item.id)} + onModify={() => handleModify(item.id)} + /> + )) + ) : ( + + )} + + + + +
+
+ ); +} diff --git a/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/page.tsx b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/page.tsx index 73c53e9..c5c9ab2 100644 --- a/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/page.tsx +++ b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/page.tsx @@ -1,6 +1,20 @@ +import { ServerFetchBoundary } from '@web/store/query/ServerFetchBoundary'; import Edit from './Edit'; import type { EditPageProps } from './types'; +import { getAllPostsQueryOptions } from '@web/store/query/useGetAllPostsQuery'; +import { getServerSideTokens } from '@web/shared/server/serverSideTokens'; export default function EditPage({ params }: EditPageProps) { - return ; + const tokens = getServerSideTokens(); + const serverFetchOptions = getAllPostsQueryOptions({ + agentId: params.agentId, + postGroupId: params.postGroupId, + tokens, + }); + + return ( + + + + ); } diff --git a/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/pageStyle.css.ts b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/pageStyle.css.ts index 527a8d6..4926004 100644 --- a/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/pageStyle.css.ts +++ b/apps/web/src/app/(prompt)/edit/[agentId]/[postGroupId]/pageStyle.css.ts @@ -2,13 +2,14 @@ import { style } from '@vanilla-extract/css'; import { vars } from '@repo/theme'; export const mainStyle = style({ - width: '100%', - height: '100vh', - background: - 'linear-gradient(174deg, rgba(255, 255, 255, 0.55) -11.84%, rgba(243, 244, 249, 0.55) 29.91%, rgba(231, 232, 251, 0.55) 100%), #FFF', + maxWidth: '100%', + minHeight: '100vh', display: 'flex', justifyContent: 'center', - overflow: 'auto', + paddingTop: '8rem', + overflowY: 'auto', + background: + 'linear-gradient(174deg, rgba(255, 255, 255, 0.55) -11.84%, rgba(243, 244, 249, 0.55) 29.91%, rgba(231, 232, 251, 0.55) 100%), #FFF', }); export const contentStyle = style({ @@ -40,3 +41,18 @@ export const accordionItemStyle = style({ width: '51.2rem', flex: '0 0 auto', }); + +export const accordionContentStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: vars.space[10], +}); + +export const contentInnerWrapper = style({ + height: '100%', +}); + +export const buttonWrapperStyle = style({ + display: 'flex', + justifyContent: 'flex-end', +}); diff --git a/apps/web/src/app/create/pageStyle.css.ts b/apps/web/src/app/create/pageStyle.css.ts index 0329406..2c6e38b 100644 --- a/apps/web/src/app/create/pageStyle.css.ts +++ b/apps/web/src/app/create/pageStyle.css.ts @@ -3,7 +3,7 @@ import { vars } from '@repo/theme'; export const mainStyle = style({ maxWidth: '100%', - height: '100vh', + minHeight: '100vh', margin: '0 auto', background: 'radial-gradient(100% 100% at 51.8% 0%, #D7DAFF 0%, #FFF 79.28%)', overflow: 'auto', diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 1533859..68700c4 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -548,7 +548,7 @@ export default function Home() { - + {/* */} ); } diff --git a/apps/web/src/components/common/DNDController/DndController.tsx b/apps/web/src/components/common/DNDController/DndController.tsx index 71fdee8..6fc1bd9 100644 --- a/apps/web/src/components/common/DNDController/DndController.tsx +++ b/apps/web/src/components/common/DNDController/DndController.tsx @@ -3,9 +3,8 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { ReactNode } from 'react'; -import { DroppableContent } from './compounds/DroppableContent/DroppableContent'; -import { DraggableContentItem } from './compounds/DraggableContentItem/DraggableContentItem'; -import { DndControllerProvider } from './context/DndContext'; +import { DroppableContent, DraggableContentItem } from './compounds'; +import { DndControllerProvider } from './context'; type SortableListProps = { items: (number | string)[]; @@ -26,5 +25,5 @@ export const DndController = Object.assign(DndControllerProvider, { Item: DraggableContentItem, }); -export { useDndController } from './context/DndContext'; -export type { DndItemData } from './context/DndContext'; +export { useDndController } from './context'; +export type { DndItemData } from './context'; diff --git a/apps/web/src/components/common/DNDController/compounds/index.ts b/apps/web/src/components/common/DNDController/compounds/index.ts new file mode 100644 index 0000000..fecb7a6 --- /dev/null +++ b/apps/web/src/components/common/DNDController/compounds/index.ts @@ -0,0 +1,3 @@ +export { ContentItem } from './ContentItem/ContentItem'; +export { DraggableContentItem } from './DraggableContentItem/DraggableContentItem'; +export { DroppableContent } from './DroppableContent/DroppableContent'; diff --git a/apps/web/src/components/common/DNDController/context/DndContext.tsx b/apps/web/src/components/common/DNDController/context/DndContext.tsx index d3d404f..2f2d120 100644 --- a/apps/web/src/components/common/DNDController/context/DndContext.tsx +++ b/apps/web/src/components/common/DNDController/context/DndContext.tsx @@ -10,8 +10,8 @@ import { closestCenter, MeasuringStrategy, } from '@dnd-kit/core'; -import { useDragAndDrop } from '../hooks/useDragAndDrop'; -import { ContentItem } from '../compounds/ContentItem/ContentItem'; +import { useDragAndDrop } from '../hooks'; +import { ContentItem } from '../compounds'; import { Post } from '@web/types'; export type DndItemData = Post; @@ -20,6 +20,7 @@ type DndControllerProviderProps = { initialItems: DndItemData[]; children: ReactNode; onDragEnd?: (items: DndItemData[]) => void; + disabled?: boolean; }; type DndControllerContextType = ReturnType; @@ -42,6 +43,7 @@ export function DndControllerProvider({ initialItems, children, onDragEnd, + disabled, }: DndControllerProviderProps) { const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), @@ -50,46 +52,49 @@ export function DndControllerProvider({ const dnd = useDragAndDrop({ initialItems, + onDragEnd, }); - const { activeId, setActiveId, items } = dnd; - const activeItem = items.find((item) => item.id === activeId); - return ( { - setActiveId(Number(active.id)); - }} - onDragOver={dnd.handleDragOver} - onDragEnd={(event) => { - dnd.handleDragEnd(event); - onDragEnd?.(items); - }} + onDragStart={ + disabled + ? undefined + : ({ active }) => { + dnd.setActiveId(Number(active.id)); + } + } + onDragOver={disabled ? undefined : dnd.handleDragOver} + onDragEnd={ + disabled + ? undefined + : (event) => { + dnd.handleDragEnd(event); + } + } measuring={{ droppable: { strategy: MeasuringStrategy.Always }, }} - modifiers={[ - (args) => ({ - ...args.transform, - scaleX: 1, - scaleY: 1, - }), - ]} > {children} - - {activeId && activeItem ? ( + + {dnd.activeId && ( dnd.handleRemove(activeItem.id)} + summary={ + dnd.items.find((item) => item.id === dnd.activeId)?.summary + } + updatedAt={ + dnd.items.find((item) => item.id === dnd.activeId)?.updatedAt || + '' + } + onRemove={() => {}} onModify={() => {}} /> - ) : null} + )} ); diff --git a/apps/web/src/components/common/DNDController/context/index.ts b/apps/web/src/components/common/DNDController/context/index.ts new file mode 100644 index 0000000..1859c98 --- /dev/null +++ b/apps/web/src/components/common/DNDController/context/index.ts @@ -0,0 +1,3 @@ +export { DndControllerProvider, useDndController } from './DndContext'; + +export type { DndItemData } from './DndContext'; diff --git a/apps/web/src/components/common/DNDController/hooks/index.ts b/apps/web/src/components/common/DNDController/hooks/index.ts new file mode 100644 index 0000000..9408968 --- /dev/null +++ b/apps/web/src/components/common/DNDController/hooks/index.ts @@ -0,0 +1 @@ +export { useDragAndDrop } from './useDragAndDrop'; diff --git a/apps/web/src/components/common/DNDController/hooks/useDragAndDrop.ts b/apps/web/src/components/common/DNDController/hooks/useDragAndDrop.ts index f71573f..7649f1d 100644 --- a/apps/web/src/components/common/DNDController/hooks/useDragAndDrop.ts +++ b/apps/web/src/components/common/DNDController/hooks/useDragAndDrop.ts @@ -1,14 +1,28 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; import { Post } from '@web/types'; +import { createItemsByStatus } from '../utils'; type UseDragAndDropProps = { initialItems: Post[]; onItemsChange?: (items: Post[]) => void; - onDragEnd?: () => void; + onDragEnd?: (items: Post[]) => void; }; +function updateDisplayOrders(items: Post[]) { + const itemsByStatus = createItemsByStatus(items); + + Object.values(itemsByStatus).forEach((statusItems) => { + statusItems.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0)); + statusItems.forEach((item, index) => { + item.displayOrder = index + 1; + }); + }); + + return Object.values(itemsByStatus).flat(); +} + export function useDragAndDrop({ initialItems, onItemsChange, @@ -17,8 +31,11 @@ export function useDragAndDrop({ const [items, setItems] = useState(initialItems); const [activeId, setActiveId] = useState(null); - const getItemsByStatus = (status: Post['status']) => - items.filter((item) => item.status === status); + const getItemsByStatus = (status: Post['status']) => { + return items + .filter((item) => item.status === status) + .sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0)); + }; const handleDragOver = (event: DragOverEvent) => { const { active, over } = event; @@ -26,10 +43,9 @@ export function useDragAndDrop({ const draggedItemId = Number(active.id); const draggedItem = items.find((item) => item.id === draggedItemId); - if (!draggedItem) return; - // 아이템 위에 있는 경우 + // 아이템 위로 드래그하는 경우 if (typeof over.id === 'number') { const overId = over.id; if (draggedItemId === overId) return; @@ -38,60 +54,71 @@ export function useDragAndDrop({ if (!overItem) return; setItems((prev) => { - const oldIndex = prev.findIndex((item) => item.id === draggedItemId); - const newIndex = prev.findIndex((item) => item.id === overId); - - // 드래그 방향이 아래쪽인지 확인 - const isBelow = event.delta.y > 0; - - // 최종 삽입 위치 계산 - let finalIndex = newIndex; - if (oldIndex < newIndex && !isBelow) { - finalIndex--; - } else if (oldIndex > newIndex && isBelow) { - finalIndex++; + const itemsByStatus = createItemsByStatus(prev); + const sourceStatus = draggedItem.status; + const targetStatus = overItem.status; + + if (sourceStatus === targetStatus) { + // 같은 상태 내에서의 이동 + const statusItems = itemsByStatus[sourceStatus]; + const oldIndex = statusItems.findIndex( + (item) => item.id === draggedItemId + ); + const newIndex = statusItems.findIndex((item) => item.id === overId); + itemsByStatus[sourceStatus] = arrayMove( + statusItems, + oldIndex, + newIndex + ); + } else { + // 다른 상태로의 이동 + itemsByStatus[sourceStatus] = itemsByStatus[sourceStatus].filter( + (item) => item.id !== draggedItemId + ); + const targetItems = itemsByStatus[targetStatus]; + const targetIndex = targetItems.findIndex( + (item) => item.id === overId + ); + targetItems.splice(targetIndex, 0, { + ...draggedItem, + status: targetStatus, + }); } - // 다른 상태로 이동하는 경우 - if (draggedItem.status !== overItem.status) { - const updatedItems = [...prev]; - updatedItems[oldIndex] = { ...draggedItem, status: overItem.status }; - return arrayMove(updatedItems, oldIndex, finalIndex); - } - - // 같은 상태 내에서 이동하는 경우 - return arrayMove(prev, oldIndex, finalIndex); + const newItems = updateDisplayOrders( + Object.values(itemsByStatus).flat() + ); + onDragEnd?.(newItems); + return newItems; }); return; } - // 컨테이너(상태) 위에 있는 경우는 이전과 동일 + // 컨테이너로 드래그하는 경우 const targetStatus = over.id as Post['status']; if (draggedItem.status === targetStatus) return; setItems((prev) => { - const oldIndex = prev.findIndex((item) => item.id === draggedItemId); - const itemsInTargetStatus = prev.filter( - (item) => item.status === targetStatus - ); - const lastItem = itemsInTargetStatus[itemsInTargetStatus.length - 1]; - const lastItemIndex = lastItem - ? prev.findIndex((item) => item.id === lastItem.id) - : -1; + const itemsByStatus = createItemsByStatus(prev); - const updatedItems = [...prev]; - updatedItems[oldIndex] = { ...draggedItem, status: targetStatus }; + itemsByStatus[draggedItem.status] = itemsByStatus[ + draggedItem.status + ].filter((item) => item.id !== draggedItemId); - const finalIndex = - lastItemIndex === -1 ? prev.length - 1 : lastItemIndex + 1; + if (!itemsByStatus[targetStatus]) itemsByStatus[targetStatus] = []; + itemsByStatus[targetStatus].push({ + ...draggedItem, + status: targetStatus, + }); - return arrayMove(updatedItems, oldIndex, finalIndex); + const newItems = updateDisplayOrders(Object.values(itemsByStatus).flat()); + onDragEnd?.(newItems); + return newItems; }); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - if (!over) return; const draggedItemId = Number(active.id); @@ -102,15 +129,36 @@ export function useDragAndDrop({ // 드롭 영역(상태)으로 이동하는 경우 if (typeof over.id === 'string') { const targetStatus = over.id as Post['status']; - - // 같은 상태면 무시 if (draggedItem.status === targetStatus) return; - setItems((prev) => - prev.map((item) => - item.id === draggedItemId ? { ...item, status: targetStatus } : item - ) - ); + setItems((prev) => { + const itemsByStatus = createItemsByStatus(prev); + const sourceItems = itemsByStatus[draggedItem.status].filter( + (item) => item.id !== draggedItemId + ); + const targetItems = itemsByStatus[targetStatus] || []; + + // 이동된 아이템을 타겟 상태의 마지막에 추가 + const updatedItem = { ...draggedItem, status: targetStatus }; + targetItems.push(updatedItem); + + // displayOrder 재계산 + sourceItems.forEach((item, index) => { + item.displayOrder = index + 1; + }); + targetItems.forEach((item, index) => { + item.displayOrder = index + 1; + }); + + itemsByStatus[draggedItem.status] = sourceItems; + itemsByStatus[targetStatus] = targetItems; + + const newItems = updateDisplayOrders( + Object.values(itemsByStatus).flat() + ); + onDragEnd?.(newItems); + return newItems; + }); return; } @@ -118,12 +166,24 @@ export function useDragAndDrop({ const overId = Number(over.id); if (draggedItemId === overId) return; - const oldIndex = items.findIndex((item) => item.id === draggedItemId); - const newIndex = items.findIndex((item) => item.id === overId); + setItems((prev) => { + const itemsByStatus = createItemsByStatus(prev); + const statusItems = itemsByStatus[draggedItem.status]; + const oldIndex = statusItems.findIndex( + (item) => item.id === draggedItemId + ); + const newIndex = statusItems.findIndex((item) => item.id === overId); + const reorderedItems = arrayMove(statusItems, oldIndex, newIndex); - setItems(arrayMove(items, oldIndex, newIndex)); + reorderedItems.forEach((item, index) => { + item.displayOrder = index + 1; + }); - onDragEnd?.(); + itemsByStatus[draggedItem.status] = reorderedItems; + const newItems = updateDisplayOrders(Object.values(itemsByStatus).flat()); + onDragEnd?.(newItems); + return newItems; + }); }; const handleRemove = (id: number) => { diff --git a/apps/web/src/components/common/DNDController/index.ts b/apps/web/src/components/common/DNDController/index.ts new file mode 100644 index 0000000..523ddf3 --- /dev/null +++ b/apps/web/src/components/common/DNDController/index.ts @@ -0,0 +1,4 @@ +export * from './context'; +export * from './hooks'; +export * from './utils'; +export { DndController } from './DndController'; diff --git a/apps/web/src/components/common/DNDController/utils/createItemsByStatus.ts b/apps/web/src/components/common/DNDController/utils/createItemsByStatus.ts new file mode 100644 index 0000000..9fde690 --- /dev/null +++ b/apps/web/src/components/common/DNDController/utils/createItemsByStatus.ts @@ -0,0 +1,14 @@ +import { Post } from '@web/types'; + +export function createItemsByStatus( + items: Post[] +): Record { + return items.reduce( + (acc, item) => { + if (!acc[item.status]) acc[item.status] = []; + acc[item.status].push(item); + return acc; + }, + {} as Record + ); +} diff --git a/apps/web/src/components/common/DNDController/utils/index.ts b/apps/web/src/components/common/DNDController/utils/index.ts new file mode 100644 index 0000000..fa73539 --- /dev/null +++ b/apps/web/src/components/common/DNDController/utils/index.ts @@ -0,0 +1 @@ +export { createItemsByStatus } from './createItemsByStatus'; diff --git a/apps/web/src/hooks/useScroll.ts b/apps/web/src/hooks/useScroll.ts index c0655f1..9fdb477 100644 --- a/apps/web/src/hooks/useScroll.ts +++ b/apps/web/src/hooks/useScroll.ts @@ -13,24 +13,19 @@ export function useScroll({ const elementRef = useRef(null); useEffect(() => { - const targetElement: HTMLElement | Window = elementRef.current ?? window; - const handleScroll = () => { requestAnimationFrame(() => { - const scrollTop = - targetElement instanceof HTMLElement - ? targetElement.scrollTop - : window.scrollY || document.documentElement.scrollTop; + const scrollTop = window.scrollY || document.documentElement.scrollTop; const scrolled = scrollTop > threshold; setIsScrolled((prev) => (prev !== scrolled ? scrolled : prev)); }); }; - targetElement.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('scroll', handleScroll, { passive: true }); handleScroll(); return () => { - targetElement.removeEventListener('scroll', handleScroll); + window.removeEventListener('scroll', handleScroll); }; }, [threshold]); diff --git a/apps/web/src/store/mutation/useCreateMorePostsMutation.ts b/apps/web/src/store/mutation/useCreateMorePostsMutation.ts new file mode 100644 index 0000000..9957ecc --- /dev/null +++ b/apps/web/src/store/mutation/useCreateMorePostsMutation.ts @@ -0,0 +1,66 @@ +import { POST } from '@web/shared/server'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@repo/ui/hooks'; +import { EditPageParams } from '@web/app/(prompt)/edit/[agentId]/[postGroupId]/types'; +import { CreatedPost } from '@web/types/post'; +import { + getAllPostsQueryOptions, + GetAllPostsResponse, +} from '../query/useGetAllPostsQuery'; +import { ApiResponse } from '@web/shared/server/types'; +import { useGetAllPostsQuery } from '../query/useGetAllPostsQuery'; + +export type MutationCreateMorePostsResponse = CreatedPost; + +/** + * 게시물 추가 생성 API + */ +export function useCreateMorePostsMutation({ + agentId, + postGroupId, +}: EditPageParams) { + const queryClient = useQueryClient(); + const toast = useToast(); + const { data: posts } = useGetAllPostsQuery({ agentId, postGroupId }); + + return useMutation({ + mutationFn: () => { + // eof가 true이면 더 이상 생성할 수 없음 + if (posts.data.postGroup.eof) { + toast.error('게시글은 25개까지만 생성할 수 있어요.'); + throw new Error('게시글은 25개까지만 생성할 수 있어요.'); + } + + return POST( + `agents/${agentId}/post-groups/${postGroupId}/posts` + ); + }, + onSuccess: (response) => { + toast.success('게시글이 5개 추가됐어요!'); + + // 현재 캐시된 데이터 가져오기 + const currentData = queryClient.getQueryData< + ApiResponse + >(getAllPostsQueryOptions({ agentId, postGroupId }).queryKey); + + if (currentData) { + // 기존 데이터와 새로운 데이터 합치기 + queryClient.setQueryData( + getAllPostsQueryOptions({ agentId, postGroupId }).queryKey, + { + ...currentData, + data: { + ...currentData.data, + posts: [...currentData.data.posts, ...response.data.posts], + }, + } + ); + } + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + } + }, + }); +} diff --git a/apps/web/src/store/mutation/useCreatePostsMutation.ts b/apps/web/src/store/mutation/useCreatePostsMutation.ts index 71060d3..194b63b 100644 --- a/apps/web/src/store/mutation/useCreatePostsMutation.ts +++ b/apps/web/src/store/mutation/useCreatePostsMutation.ts @@ -3,17 +3,13 @@ import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { CreateFormValues } from '@web/app/create/types'; import { useToast } from '@repo/ui/hooks'; -import { Post } from '@web/types'; +import { CreatedPost } from '@web/types/post'; export type MutationCreatePostsType = { agentId: string; }; -export interface MutationCreatePostsResponse { - postGroupId: number; - eof: boolean; - posts: Post[]; -} +export type MutationCreatePostsResponse = CreatedPost; type MutationCreatePostsRequest = CreateFormValues; diff --git a/apps/web/src/store/mutation/useDeletePostMutation.ts b/apps/web/src/store/mutation/useDeletePostMutation.ts new file mode 100644 index 0000000..89b09b2 --- /dev/null +++ b/apps/web/src/store/mutation/useDeletePostMutation.ts @@ -0,0 +1,37 @@ +import { DELETE } from '@web/shared/server'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@repo/ui/hooks'; +import { EditPageParams } from '@web/app/(prompt)/edit/[agentId]/[postGroupId]/types'; +import { getAllPostsQueryOptions } from '../query/useGetAllPostsQuery'; +import { Post } from '@web/types'; + +/** + * 게시물 개별 삭제 API + * + * 업로드가 확정되지 않은 단건의 게시물을 개별 삭제합니다. (생성됨, 수정 중, 수정 완료) + * + * 업로드가 확정된 상태의 게시물은 삭제할 수 없습니다. (예약 완료, 업로드 완료, 업로드 실패) + */ +export function useDeletePostMutation({ + agentId, + postGroupId, +}: EditPageParams) { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: (postId: Post['id']) => + DELETE(`agents/${agentId}/post-groups/${postGroupId}/posts/${postId}`), + onSuccess: () => { + toast.success('게시글이 삭제되었어요.'); + queryClient.invalidateQueries( + getAllPostsQueryOptions({ agentId, postGroupId }) + ); + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + } + }, + }); +} diff --git a/apps/web/src/store/mutation/useUpdatePostsMutation.ts b/apps/web/src/store/mutation/useUpdatePostsMutation.ts new file mode 100644 index 0000000..b99a715 --- /dev/null +++ b/apps/web/src/store/mutation/useUpdatePostsMutation.ts @@ -0,0 +1,46 @@ +import { PUT } from '@web/shared/server'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@repo/ui/hooks'; +import { EditPageParams } from '@web/app/(prompt)/edit/[agentId]/[postGroupId]/types'; +import { PostStatus } from '@web/types/post'; +import { getAllPostsQueryOptions } from '../query/useGetAllPostsQuery'; + +interface UpdatePostPayload { + postId?: number; + status?: PostStatus; + uploadTime?: string; + displayOrder?: number; +} + +interface UpdatePostsRequest { + posts: UpdatePostPayload[]; +} + +/** + * + * 게시물 기타 정보 수정 API + * + * 기존 여러 게시물들의 상태 / 업로드 예약 일시 / 순서를 수정합니다. + */ +export function useUpdatePostsMutation({ + agentId, + postGroupId, +}: EditPageParams) { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: (data: UpdatePostsRequest) => + PUT(`agents/${agentId}/post-groups/${postGroupId}/posts`, data), + onSuccess: () => { + queryClient.invalidateQueries( + getAllPostsQueryOptions({ agentId, postGroupId }) + ); + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + } + }, + }); +} diff --git a/apps/web/src/store/mutation/useUpdatePromptMutation.ts b/apps/web/src/store/mutation/useUpdatePromptMutation.ts new file mode 100644 index 0000000..9d0b999 --- /dev/null +++ b/apps/web/src/store/mutation/useUpdatePromptMutation.ts @@ -0,0 +1,40 @@ +import { PATCH } from '@web/shared/server'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@repo/ui/hooks'; +import { EditPageParams } from '@web/app/(prompt)/edit/[agentId]/[postGroupId]/types'; +import { getAllPostsQueryOptions } from '../query/useGetAllPostsQuery'; +import { Post } from '@web/types'; + +export interface UpdatePromptRequest { + prompt: string; + postsId: Post['id'][]; +} + +/** + * 게시물 프롬프트 기반 일괄 수정 + * + * 일괄 게시물에 대해 입력된 프롬프트를 바탕으로 수정합니다. + */ +export function useUpdatePromptMutation({ + agentId, + postGroupId, +}: EditPageParams) { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: (data: UpdatePromptRequest) => + PATCH(`agents/${agentId}/post-groups/${postGroupId}/posts/prompt`, data), + onSuccess: () => { + toast.success('프롬프트가 적용되었어요!'); + queryClient.invalidateQueries( + getAllPostsQueryOptions({ agentId, postGroupId }) + ); + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + } + }, + }); +} diff --git a/apps/web/src/store/query/useGetAllPostsQuery.ts b/apps/web/src/store/query/useGetAllPostsQuery.ts new file mode 100644 index 0000000..20c58bf --- /dev/null +++ b/apps/web/src/store/query/useGetAllPostsQuery.ts @@ -0,0 +1,46 @@ +import { GET } from '@web/shared/server'; +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; +import { Post } from '@web/types'; +import { EditPageParams } from '@web/app/(prompt)/edit/[agentId]/[postGroupId]/types'; +import { PostGroup } from '@web/types/post'; +import { Tokens } from '@web/shared/server/types'; + +const STALE_TIME = 1000 * 60 * 1; +const GC_TIME = 1000 * 60 * 1; + +export type GetAllPostsParams = EditPageParams & { + tokens?: Tokens; +}; + +export interface GetAllPostsResponse { + postGroup: PostGroup; + posts: Post[]; +} + +/** + * 게시물 그룹별 게시물 목록 조회 API + * + * 게시물 그룹에 해당되는 모든 게시물 목록을 조회합니다. + */ +export function getAllPostsQueryOptions({ + agentId, + postGroupId, + tokens, +}: GetAllPostsParams) { + return queryOptions({ + queryKey: ['posts', agentId, postGroupId], + queryFn: () => + GET( + `agents/${agentId}/post-groups/${postGroupId}/posts`, + undefined, + tokens + ), + // NOTE: 항상 fresh 상태로 유지 + staleTime: STALE_TIME, + gcTime: GC_TIME, + }); +} + +export function useGetAllPostsQuery(params: GetAllPostsParams) { + return useSuspenseQuery(getAllPostsQueryOptions(params)); +} diff --git a/apps/web/src/types/post.ts b/apps/web/src/types/post.ts index 250cf47..1d155ba 100644 --- a/apps/web/src/types/post.ts +++ b/apps/web/src/types/post.ts @@ -13,7 +13,7 @@ export const POST_STATUS = { UPLOAD_FAILED: 'UPLOAD_FAILED', } as const; -type PostStatus = (typeof POST_STATUS)[keyof typeof POST_STATUS]; +export type PostStatus = (typeof POST_STATUS)[keyof typeof POST_STATUS]; export interface Post { id: number; @@ -24,4 +24,47 @@ export interface Post { postImages: PostImage[]; status: PostStatus; uploadTime: string; + displayOrder?: number; + isLoading?: boolean; +} + +export interface CreatedPost { + postGroupId: number; + eof: boolean; + posts: Post[]; +} + +export type Purpose = 'INFORMATION' | 'OPINION' | 'HUMOR' | 'MARKETING'; + +export type Reference = 'NONE' | 'NEWS' | 'IMAGE'; + +export type NewsCategory = + | 'INVEST' + | 'STOCK' + | 'REALESTATE' + | 'FASHION' + | 'TRAVEL' + | 'BEAUTY' + | 'FITNESS' + | 'COOKING' + | 'HEALTHCARE' + | 'AI' + | 'GAME' + | 'APP' + | 'SPACE' + | 'ENVIRONMENT' + | 'ENGINEER'; + +export type PostGroupLength = 'SHORT' | 'MEDIUM' | 'LONG'; + +export interface PostGroup { + id: number; + topic: string; + purpose: Purpose; + reference: Reference; + newsCategory: NewsCategory | null; + postGroupImages: PostImage[]; + length: PostGroupLength; + content: string; + eof: boolean; } diff --git a/packages/ui/src/components/TextField/TextField.css.ts b/packages/ui/src/components/TextField/TextField.css.ts index eb491c8..5258a1f 100644 --- a/packages/ui/src/components/TextField/TextField.css.ts +++ b/packages/ui/src/components/TextField/TextField.css.ts @@ -29,6 +29,11 @@ export const textFieldContainerStyle = recipe({ backgroundColor: vars.colors.grey50, paddingRight: '4.8rem', }, + white: { + backgroundColor: vars.colors.grey, + paddingRight: '4.8rem', + border: `1px solid ${vars.colors.grey100}`, + }, }, }, }); @@ -47,9 +52,6 @@ export const textFieldStyle = recipe({ paddingRight: vars.space[4], maxHeight: `calc(${vars.typography.fontSize[18]} * 11 * 1.5)`, overflowY: 'auto', - '::placeholder': { - color: vars.colors.grey400, - }, selectors: { '&::-webkit-scrollbar': { width: '0.6rem', @@ -62,6 +64,9 @@ export const textFieldStyle = recipe({ '&::-webkit-scrollbar-track': { backgroundColor: 'transparent', }, + '&::placeholder': { + color: vars.colors.grey400, + }, }, scrollbarWidth: 'thin', scrollbarColor: `${vars.colors.grey200} transparent`, @@ -70,15 +75,12 @@ export const textFieldStyle = recipe({ variant: { default: { backgroundColor: vars.colors.grey25, - '::placeholder': { - color: vars.colors.grey400, - }, }, button: { backgroundColor: vars.colors.grey50, - '::placeholder': { - color: vars.colors.grey400, - }, + }, + white: { + backgroundColor: vars.colors.grey, }, }, }, @@ -105,6 +107,11 @@ export const submitButtonStyle = recipe({ cursor: 'not-allowed', }, }, + isDisabled: { + true: { + cursor: 'not-allowed', + }, + }, }, }); diff --git a/packages/ui/src/components/TextField/TextFieldInput.tsx b/packages/ui/src/components/TextField/TextFieldInput.tsx index 56c778b..5d913c9 100644 --- a/packages/ui/src/components/TextField/TextFieldInput.tsx +++ b/packages/ui/src/components/TextField/TextFieldInput.tsx @@ -12,7 +12,6 @@ import { TextFieldContext } from './context'; import { textFieldContainerStyle, textFieldStyle } from './TextField.css'; import { TextFieldCounter } from './TextFieldCounter'; import { isNil, mergeRefs } from '../../utils'; -import { TextFieldSubmit } from './TextFieldSubmit'; export type TextFieldInputProps = { maxLength?: number; diff --git a/packages/ui/src/components/TextField/TextFieldSubmit.tsx b/packages/ui/src/components/TextField/TextFieldSubmit.tsx index 0399104..16da182 100644 --- a/packages/ui/src/components/TextField/TextFieldSubmit.tsx +++ b/packages/ui/src/components/TextField/TextFieldSubmit.tsx @@ -11,19 +11,24 @@ export type TextFieldSubmitProps = Omit< export const TextFieldSubmit = forwardRef< HTMLButtonElement, TextFieldSubmitProps ->(({ className = '', type = 'button', ...props }, ref) => { +>(({ className = '', type = 'button', disabled, ...props }, ref) => { const { variant, isError } = useContext(TextFieldContext); - if (variant !== 'button') return null; + if (variant !== 'button' && variant !== 'white') return null; return ( ); }); diff --git a/packages/ui/src/components/TextField/context.ts b/packages/ui/src/components/TextField/context.ts index 0ca59ea..f90e941 100644 --- a/packages/ui/src/components/TextField/context.ts +++ b/packages/ui/src/components/TextField/context.ts @@ -1,6 +1,6 @@ import { createContext } from 'react'; -export type TextFieldVariant = 'default' | 'button'; +export type TextFieldVariant = 'default' | 'button' | 'white'; export type TextFieldContextValue = { id?: string;