diff --git a/src/frontend/apps/web/app/api/auth/token/route.ts b/src/frontend/apps/web/app/api/auth/token/route.ts new file mode 100644 index 00000000..a5251d4c --- /dev/null +++ b/src/frontend/apps/web/app/api/auth/token/route.ts @@ -0,0 +1,31 @@ +import { cookies, headers } from 'next/headers'; +import { NextResponse } from 'next/server'; + +const allowedOrigins = [ + 'https://jootalkpia.netlify.app', + 'http://localhost:3000', +]; + +export async function GET() { + const cookieStore = cookies(); + const token = cookieStore.get('accessToken')?.value || ''; + + const origin = allowedOrigins.includes(headers().get('origin') || '') + ? headers().get('origin') + : allowedOrigins[0]; + + const response = NextResponse.json({ token }); + + response.headers.set('Access-Control-Allow-Origin', origin || ''); + response.headers.set('Access-Control-Allow-Credentials', 'true'); + response.headers.set( + 'Access-Control-Allow-Methods', + 'GET, POST, PATCH, DELETE', + ); + response.headers.set( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization', + ); + + return response; +} diff --git a/src/frontend/apps/web/app/stock/[stockSlug]/page.tsx b/src/frontend/apps/web/app/stock/[stockSlug]/page.tsx index 82dfb18a..d1353627 100644 --- a/src/frontend/apps/web/app/stock/[stockSlug]/page.tsx +++ b/src/frontend/apps/web/app/stock/[stockSlug]/page.tsx @@ -1,7 +1,10 @@ +import Link from 'next/link'; + +import { ChevronLeft } from 'lucide-react'; + import { ChatContainer } from '@/src/features/chat'; import { StockDetailLayout } from '@/src/features/stock'; -import { ChevronLeft } from 'lucide-react'; -import Link from 'next/link'; +import { ChatIdProvider } from '@/src/shared'; export async function generateMetadata({ params }) { const { stockSlug } = params; @@ -15,7 +18,6 @@ export async function generateMetadata({ params }) { export default function StockDetailsPage({ params }) { const { stockSlug } = params; - // console.log(1, stockSlug); return (
@@ -30,7 +32,9 @@ export default function StockDetailsPage({ params }) {
- + + +
); diff --git a/src/frontend/apps/web/package.json b/src/frontend/apps/web/package.json index 66a97375..a9c5011a 100644 --- a/src/frontend/apps/web/package.json +++ b/src/frontend/apps/web/package.json @@ -10,6 +10,7 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^4.1.2", "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.64.2", "@tanstack/react-table": "^8.20.6", @@ -18,9 +19,11 @@ "lucide-react": "0.473.0", "next": "^14.2.23", "next-themes": "^0.4.4", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "sockjs-client": "^1.6.1", + "zod": "^3.24.1", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/src/frontend/apps/web/src/features/.gitkeep b/src/frontend/apps/web/src/features/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/frontend/apps/web/src/features/auth/model/use-logout.ts b/src/frontend/apps/web/src/features/auth/model/use-logout.ts index 3763fc27..35d273df 100644 --- a/src/frontend/apps/web/src/features/auth/model/use-logout.ts +++ b/src/frontend/apps/web/src/features/auth/model/use-logout.ts @@ -9,6 +9,7 @@ export const useLogout = () => { return () => { localStorage.removeItem('user'); + localStorage.removeItem('chat'); setUser(null); document.cookie = 'accessToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; diff --git a/src/frontend/apps/web/src/features/chat/api/get-history-chat.api.ts b/src/frontend/apps/web/src/features/chat/api/get-history-chat.api.ts index 3625af58..886a1c07 100644 --- a/src/frontend/apps/web/src/features/chat/api/get-history-chat.api.ts +++ b/src/frontend/apps/web/src/features/chat/api/get-history-chat.api.ts @@ -1,4 +1,4 @@ -import { clientFetchInstance } from '@/src/shared/services'; +import { clientFetchInstance, TAG_KEYS } from '@/src/shared/services'; import type { HistoryResponse } from '../model'; @@ -20,6 +20,9 @@ export const getHistoryChat = async ( { params, includeAuthToken: true, + cache: 'force-cache', + revalidate: 300, + tags: [`${TAG_KEYS.CHAT_HISTORY(channelId)}`], }, ); }; diff --git a/src/frontend/apps/web/src/features/chat/api/upload-chunk-retry-api.ts b/src/frontend/apps/web/src/features/chat/api/upload-chunk-retry-api.ts index ae5b2fa7..94253f99 100644 --- a/src/frontend/apps/web/src/features/chat/api/upload-chunk-retry-api.ts +++ b/src/frontend/apps/web/src/features/chat/api/upload-chunk-retry-api.ts @@ -1,6 +1,7 @@ -import { ResponseChunkFileData } from '../model'; import { uploadFiles } from './upload-file.api'; +import { ResponseChunkFileData } from '../model'; + export const uploadChunkWithRetry = async ( chunk: Blob, channelId: number, diff --git a/src/frontend/apps/web/src/features/chat/api/upload-file.api.ts b/src/frontend/apps/web/src/features/chat/api/upload-file.api.ts index dab9c8bf..3f4d5441 100644 --- a/src/frontend/apps/web/src/features/chat/api/upload-file.api.ts +++ b/src/frontend/apps/web/src/features/chat/api/upload-file.api.ts @@ -1,6 +1,7 @@ -import { ResponseChunkFileData } from '../model'; import { clientFetchInstance } from '@/src/shared/services/apis'; +import { ResponseChunkFileData } from '../model'; + /** * 각 청크 데이터를 개별적으로 업로드하는 함수. * 이 함수는 하나의 청크(ChunkInfo 객체)를 API로 전송합니다. diff --git a/src/frontend/apps/web/src/features/chat/api/upload-smallfile.api.ts b/src/frontend/apps/web/src/features/chat/api/upload-smallfile.api.ts index b989c4c3..dc76882c 100644 --- a/src/frontend/apps/web/src/features/chat/api/upload-smallfile.api.ts +++ b/src/frontend/apps/web/src/features/chat/api/upload-smallfile.api.ts @@ -1,5 +1,6 @@ +import { serverFetchInstance } from '@/src/shared/services/apis'; + import { type ResponseChunkFileData } from '../model'; -import { clientFetchInstance } from '@/src/shared/services/apis'; /** * 각 청크 데이터를 개별적으로 업로드하는 함수. @@ -22,11 +23,13 @@ export async function uploadSmallFiles({ // } try { - const response = await clientFetchInstance( + console.log('start upload'); + const response = await serverFetchInstance( '/api/v1/files/small', 'POST', { body: formData, + includeAuthToken: true, }, ); console.log('[uploadFiles] Response =>', response); diff --git a/src/frontend/apps/web/src/features/chat/api/upload-thumbnail.api.ts b/src/frontend/apps/web/src/features/chat/api/upload-thumbnail.api.ts index f6bf58e6..a3b14740 100644 --- a/src/frontend/apps/web/src/features/chat/api/upload-thumbnail.api.ts +++ b/src/frontend/apps/web/src/features/chat/api/upload-thumbnail.api.ts @@ -1,5 +1,6 @@ +import { serverFetchInstance } from '@/src/shared/services/apis'; + import type { FileResponse } from '../model'; -import { postRequest } from '@/src/shared/services/apis'; type ThumbnailData = { fileId: number; @@ -25,9 +26,13 @@ export async function uploadThumbnail({ // } try { - const response = await postRequest( + const response = await serverFetchInstance( '/api/v1/files/thumbnail', - formData, + 'POST', + { + body: formData, + includeAuthToken: true, + }, ); console.log('[uploadFiles] Response =>', response); return response; diff --git a/src/frontend/apps/web/src/features/chat/lib/validate-filesize.util.ts b/src/frontend/apps/web/src/features/chat/lib/validate-filesize.util.ts index 24c5163e..86c6e956 100644 --- a/src/frontend/apps/web/src/features/chat/lib/validate-filesize.util.ts +++ b/src/frontend/apps/web/src/features/chat/lib/validate-filesize.util.ts @@ -2,7 +2,7 @@ export const MAX_IMAGE_SIZE = 20 * 1024 * 1024; export const MAX_VIDEO_SIZE = 200 * 1024 * 1024; export const validateFileSize = (file: File) => { - console.log('File type:', file.type); + // console.log('File type:', file.type); if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) { alert('Only image and video files are supported'); diff --git a/src/frontend/apps/web/src/features/chat/model/index.ts b/src/frontend/apps/web/src/features/chat/model/index.ts index 1288d27f..1e03c632 100644 --- a/src/frontend/apps/web/src/features/chat/model/index.ts +++ b/src/frontend/apps/web/src/features/chat/model/index.ts @@ -12,8 +12,8 @@ export type { SendMessagePayload, WebSocketResponsePayload, } from './websocket.type'; -export { useMessages } from './use-messages'; -export { useWebSocketClient } from './use-websocket-client'; +export { useChatMessages } from './use-chat-messages'; +export { useChatSubscribe } from './use-chat-subscribe'; export { useChatAutoScroll } from './use-chat-autoscroll'; export { useSendMessage } from './send-message'; export { useReverseInfiniteHistory } from './use-reverse-infinite-history'; diff --git a/src/frontend/apps/web/src/features/chat/model/send-message.ts b/src/frontend/apps/web/src/features/chat/model/send-message.ts index a2c037c3..a15b4c44 100644 --- a/src/frontend/apps/web/src/features/chat/model/send-message.ts +++ b/src/frontend/apps/web/src/features/chat/model/send-message.ts @@ -1,10 +1,10 @@ -import { useWebSocketClient } from '@/src/features/chat/model'; +import { useChatSubscribe } from '@/src/features/chat/model'; export const useSendMessage = ( channelId: number, currentUser: { userId: number; nickname: string; profileImage: string }, ) => { - const { publishMessage } = useWebSocketClient(channelId); + const { publishMessage } = useChatSubscribe(channelId); return (content: string, attachmentList: number[]) => { if (!content.trim() && attachmentList.length === 0) return; diff --git a/src/frontend/apps/web/src/features/chat/model/use-messages.ts b/src/frontend/apps/web/src/features/chat/model/use-chat-messages.ts similarity index 90% rename from src/frontend/apps/web/src/features/chat/model/use-messages.ts rename to src/frontend/apps/web/src/features/chat/model/use-chat-messages.ts index 5457165c..2fccf9bc 100644 --- a/src/frontend/apps/web/src/features/chat/model/use-messages.ts +++ b/src/frontend/apps/web/src/features/chat/model/use-chat-messages.ts @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { WebSocketResponsePayload } from '@/src/features/chat/model'; -export const useMessages = (topic: string) => { +export const useChatMessages = (topic: string) => { const queryClient = useQueryClient(); const queryKey = ['messages', topic]; diff --git a/src/frontend/apps/web/src/features/chat/model/use-websocket-client.ts b/src/frontend/apps/web/src/features/chat/model/use-chat-subscribe.ts similarity index 94% rename from src/frontend/apps/web/src/features/chat/model/use-websocket-client.ts rename to src/frontend/apps/web/src/features/chat/model/use-chat-subscribe.ts index 8eca9137..1a5497a0 100644 --- a/src/frontend/apps/web/src/features/chat/model/use-websocket-client.ts +++ b/src/frontend/apps/web/src/features/chat/model/use-chat-subscribe.ts @@ -9,7 +9,7 @@ import type { import { useStompWebSocket } from '@/src/shared/providers'; import { QUERY_KEYS } from '@/src/shared/services'; -export const useWebSocketClient = (channelId: number) => { +export const useChatSubscribe = (channelId: number) => { const queryClient = useQueryClient(); const { client } = useStompWebSocket(); const [isConnected, setIsConnected] = useState(false); @@ -33,7 +33,7 @@ export const useWebSocketClient = (channelId: number) => { return; } - console.log(`📡 Subscribing to /subscribe/chat.${channelId}`); + // console.log(`📡 Subscribing to /subscribe/chat.${channelId}`); const subscription = client.subscribe( `/subscribe/chat.${channelId}`, (message) => { diff --git a/src/frontend/apps/web/src/features/chat/model/use-forward-infinite-history.ts b/src/frontend/apps/web/src/features/chat/model/use-forward-infinite-history.ts index 196a8b70..5f8b9ceb 100644 --- a/src/frontend/apps/web/src/features/chat/model/use-forward-infinite-history.ts +++ b/src/frontend/apps/web/src/features/chat/model/use-forward-infinite-history.ts @@ -5,19 +5,23 @@ import { QUERY_KEYS } from '@/src/shared/services'; import { getHistoryChat } from '../api'; import type { HistoryResponse } from '../model'; -export function useForwardInfiniteHistory( - channelId: number, - initialCursor?: number, -) { +export function useForwardInfiniteHistory(channelId: number) { + const queryKey = QUERY_KEYS.forwardHistory(channelId); + return useInfiniteQuery({ - queryKey: QUERY_KEYS.forwardHistory(channelId), + queryKey, queryFn: async ({ pageParam }: { pageParam?: unknown }) => { + if (pageParam === undefined) { + return getHistoryChat(channelId, 'forward', undefined, 5); + } return getHistoryChat(channelId, 'forward', pageParam as number, 5); }, getNextPageParam: (lastPage: HistoryResponse): number | undefined => lastPage.hasNext && lastPage.lastCursorId !== null ? lastPage.lastCursorId : undefined, - initialPageParam: initialCursor, + initialPageParam: undefined, + staleTime: Infinity, + gcTime: Infinity, }); } diff --git a/src/frontend/apps/web/src/features/chat/model/use-reverse-infinite-history.ts b/src/frontend/apps/web/src/features/chat/model/use-reverse-infinite-history.ts index d13dd22e..625189b4 100644 --- a/src/frontend/apps/web/src/features/chat/model/use-reverse-infinite-history.ts +++ b/src/frontend/apps/web/src/features/chat/model/use-reverse-infinite-history.ts @@ -5,19 +5,31 @@ import { QUERY_KEYS } from '@/src/shared/services'; import { getHistoryChat } from '../api'; import type { HistoryResponse } from '../model'; -export function useReverseInfiniteHistory( - channelId: number, - initialCursor?: number, -) { +export function useReverseInfiniteHistory(channelId: number) { + const queryKey = QUERY_KEYS.reverseHistory(channelId); + return useInfiniteQuery({ - queryKey: QUERY_KEYS.reverseHistory(channelId), - queryFn: async ({ pageParam }: { pageParam: unknown }) => { - return getHistoryChat(channelId, 'backward', pageParam as number, 30); + queryKey, + queryFn: async ({ pageParam }: { pageParam?: unknown }) => { + if (pageParam === undefined) { + const data = getHistoryChat(channelId, 'backward', undefined, 30); + return data; + } + const data = getHistoryChat( + channelId, + 'backward', + pageParam as number, + 30, + ); + return data; }, getNextPageParam: (lastPage: HistoryResponse): number | undefined => lastPage.hasNext && lastPage.lastCursorId !== null ? lastPage.lastCursorId : undefined, - initialPageParam: initialCursor, + initialPageParam: undefined, + + staleTime: Infinity, + gcTime: Infinity, }); } diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-container.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-container.tsx index 3a178063..086fac16 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-container.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-container.tsx @@ -5,16 +5,17 @@ import { SidebarContainer } from '@/src/features/workspace'; import ChatHeader from './chat-header'; import ChatSection from './chat-section'; -import { useWebSocketClient } from '../model'; +import { useChatSubscribe } from '../model'; import { useEffect } from 'react'; +import { useChatId } from '@/src/shared'; type ChatContainerProps = { stockSlug: string; }; const ChatContainer = ({ stockSlug }: ChatContainerProps) => { - const channelId = 1; - const { subscribe, isConnected } = useWebSocketClient(channelId); + const { channelId } = useChatId(); + const { subscribe, isConnected } = useChatSubscribe(channelId); useEffect(() => { if (!isConnected) return; @@ -30,7 +31,7 @@ const ChatContainer = ({ stockSlug }: ChatContainerProps) => { - + ); diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-history-forward.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-history-forward.tsx index f96a0df5..9f5af017 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-history-forward.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-history-forward.tsx @@ -1,20 +1,26 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Loader2 } from 'lucide-react'; import { useForwardInfiniteHistory } from '@/src/features/chat/model'; import { processChatHistory } from '../lib/process-chat-history.util'; import ChatHistoryItem from './chat-history-item'; import { Badge, Separator } from '@workspace/ui/components'; +import { useChatId } from '@/src/shared'; export type ChatHistoryProps = { containerRef: React.RefObject; }; const ChatForwardHistory = ({ containerRef }: ChatHistoryProps) => { - const channelId = 1; - const initialCursor = undefined; + const { channelId } = useChatId(); + + const [isNearBottom, setIsNearBottom] = useState(false); + const scrollThreshold = 100; + const scrollTimerRef = useRef(null); + const isLoadingRef = useRef(false); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - useForwardInfiniteHistory(channelId, initialCursor); + useForwardInfiniteHistory(Number(channelId)); const messages = data?.pages.flatMap((page) => page.threads) ?? []; const processedThreads = processChatHistory(messages); @@ -31,23 +37,62 @@ const ChatForwardHistory = ({ containerRef }: ChatHistoryProps) => { if (!container) return; const handleScroll = () => { - if ( - container.scrollTop + container.clientHeight >= - container.scrollHeight - 10 && - hasNextPage && - !isFetchingNextPage - ) { - const prevScrollHeight = container.scrollHeight; - fetchNextPage().then(() => { - const newScrollHeight = container.scrollHeight; - container.scrollTop += newScrollHeight - prevScrollHeight; - }); + if (isLoadingRef.current) return; + + const distanceToBottom = + container.scrollHeight - (container.scrollTop + container.clientHeight); + const isCloseToBottom = distanceToBottom <= scrollThreshold; + + if (isCloseToBottom && !isNearBottom) { + setIsNearBottom(true); + } else if (!isCloseToBottom && isNearBottom) { + setIsNearBottom(false); } }; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); - }, [containerRef, hasNextPage, isFetchingNextPage, fetchNextPage]); + }, [containerRef, isNearBottom]); + + useEffect(() => { + if (isNearBottom && hasNextPage && !isFetchingNextPage) { + if (scrollTimerRef.current) { + clearTimeout(scrollTimerRef.current); + } + scrollTimerRef.current = setTimeout(() => { + const container = containerRef.current; + if (!container) return; + + const prevScrollTop = container.scrollTop; + const prevScrollHeight = container.scrollHeight; + isLoadingRef.current = true; + + fetchNextPage() + .then(() => { + const newScrollHeight = container.scrollHeight; + + container.scrollTop = + prevScrollTop + (newScrollHeight - prevScrollHeight); + isLoadingRef.current = false; + }) + .catch(() => { + isLoadingRef.current = false; + }); + }, 300); + } + + return () => { + if (scrollTimerRef.current) { + clearTimeout(scrollTimerRef.current); + } + }; + }, [ + isNearBottom, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + containerRef, + ]); return ( <> @@ -71,6 +116,7 @@ const ChatForwardHistory = ({ containerRef }: ChatHistoryProps) => { thread={thread} /> ))} + {hasNextPage && isFetchingNextPage && (
; }; const ChatReverseHistory = ({ containerRef }: ChatHistoryProps) => { - const channelId = 1; - const initialCursor = undefined; + const { channelId } = useChatId(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - useReverseInfiniteHistory(channelId, initialCursor); + useReverseInfiniteHistory(channelId); + + const [isNearTop, setIsNearTop] = useState(false); + const scrollThreshold = 50; + const scrollTimerRef = useRef(null); + const isLoadingRef = useRef(false); + + console.log(data?.pages[0].lastCursorId); const messages = data?.pages.flatMap((page) => page.threads) ?? []; @@ -33,19 +40,51 @@ const ChatReverseHistory = ({ containerRef }: ChatHistoryProps) => { if (!container) return; const handleScroll = () => { - if (container.scrollTop === 0 && hasNextPage && !isFetchingNextPage) { - const prevScrollHeight = container.scrollHeight; - fetchNextPage().then(() => { - const newScrollHeight = container.scrollHeight; + if (isLoadingRef.current) return; + const isCloseToTop = container.scrollTop <= scrollThreshold; - container.scrollTop = newScrollHeight - prevScrollHeight; - }); + if (isCloseToTop && !isNearTop) { + setIsNearTop(true); + } else if (!isCloseToTop && isNearTop) { + setIsNearTop(false); } }; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); - }, [containerRef, hasNextPage, isFetchingNextPage, fetchNextPage]); + }, [containerRef, isNearTop]); + + useEffect(() => { + if (isNearTop && hasNextPage && !isFetchingNextPage) { + if (scrollTimerRef.current) { + clearTimeout(scrollTimerRef.current); + } + + scrollTimerRef.current = setTimeout(() => { + const container = containerRef.current; + if (!container) return; + + const prevScrollHeight = container.scrollHeight; + isLoadingRef.current = true; + + fetchNextPage() + .then(() => { + const newScrollHeight = container.scrollHeight; + container.scrollTop = newScrollHeight - prevScrollHeight; + isLoadingRef.current = false; + }) + .catch(() => { + isLoadingRef.current = false; + }); + }, 300); + } + + return () => { + if (scrollTimerRef.current) { + clearTimeout(scrollTimerRef.current); + } + }; + }, [isNearTop, hasNextPage, isFetchingNextPage, fetchNextPage, containerRef]); return ( <> diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-message-list.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-message-list.tsx index ae02b35a..172cbed0 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-message-list.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-message-list.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react'; - import ChatMessageItem from './chat-message-item'; import ChatReverseHistory from './chat-history-reverse'; import ChatForwardHistory from './chat-history-forward'; @@ -18,7 +16,6 @@ const ChatMessageList = ({ messages = [], }: ChatContentProps) => { const { bottomRef, containerRef } = useChatAutoScroll(messages); - const [isAtBottom, setIsAtBottom] = useState(true); // if (!messages || messages.length === 0) return null; // console.log('🔗 ChatContent:', { messages }); diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-section.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-section.tsx index 1fb8a66f..098b98dd 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-section.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-section.tsx @@ -1,20 +1,37 @@ 'use client'; -import { useMessages } from '@/src/features/chat/model'; +import { useChatMessages } from '@/src/features/chat/model'; +import { getUserIdFromCookie } from '@/src/shared/services'; import ChatItemList from './chat-message-list'; import ChatTextarea from './chat-textarea'; import { useSendMessage } from '../model'; +import { useEffect, useState } from 'react'; +import { useChatId } from '@/src/shared'; -const ChatSection = () => { - const channelId = 1; - const user = JSON.parse(localStorage.getItem('user') || '{}'); - const currentUser = { - userId: user.userId, - nickname: user.nickname, - profileImage: user.profileImage, - }; - const { data: messages } = useMessages(`/subscribe/chat.${channelId}`); +const ChatSection = ({ stockSlug }: { stockSlug: string }) => { + const { channelId } = useChatId(); + + const [currentUser, setCurrentUser] = useState({ + userId: null, + nickname: '', + profileImage: '', + }); + useEffect(() => { + async function fetchUser() { + const userId = await getUserIdFromCookie(); + const storedUser = JSON.parse(localStorage.getItem('user')); + + setCurrentUser({ + userId, + nickname: storedUser.nickname || '', + profileImage: storedUser.profileImage || '', + }); + } + fetchUser(); + }, []); + + const { data: messages } = useChatMessages(`/subscribe/chat.${channelId}`); const handleSendMessage = useSendMessage(channelId, currentUser); return ( @@ -24,7 +41,10 @@ const ChatSection = () => {
- +
diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx index 3c1e3e64..4f4c63f8 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx @@ -1,8 +1,10 @@ 'use client'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Textarea } from '@workspace/ui/components'; +import { getUserIdFromCookie } from '@/src/shared/services'; +import { useChatId } from '@/src/shared'; import ChatToggleGroup from './chat-toggle-group'; @@ -12,13 +14,24 @@ const ChatTextArea = ({ onSend, }: { onSend: (content: string, attachmentList: number[]) => void; + stockSlug?: string; }) => { const [message, setMessage] = useState(''); const [attachmentList, setAttachmentList] = useState([]); const isComposing = useRef(false); - const user = JSON.parse(localStorage.getItem('user') || '{}'); + const [userId, setUserId] = useState(null); + const { channelId, workspaceId } = useChatId(); - const fileManagements = useFileManagements(1, 1, user.userId); + useEffect(() => { + const fetchUserId = async () => { + const id = await getUserIdFromCookie(); + setUserId(id); + }; + + fetchUserId(); + }, []); + + const fileManagements = useFileManagements(workspaceId, channelId, userId); const { setFilePreviews, setUploadedFileIds } = fileManagements; const handleSendClick = () => { diff --git a/src/frontend/apps/web/src/features/chat/ui/content-text.tsx b/src/frontend/apps/web/src/features/chat/ui/content-text.tsx index b88ace67..8b4c5410 100644 --- a/src/frontend/apps/web/src/features/chat/ui/content-text.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/content-text.tsx @@ -4,15 +4,12 @@ import { Badge } from '@workspace/ui/components'; import type { WebSocketResponsePayload } from '../model'; import { formatChatTime } from '../lib'; -// import { MessageSquareText } from 'lucide-react'; -// import AvatarList from './avatarlist'; -// import type { ChatContentWithAvatarsProps } from './chat-content'; export type ContentTextProps = { type?: 'default' | 'live'; - // avatarUrls?: string[]; + message: WebSocketResponsePayload; - // setIsThreadOpen: (value: boolean) => void; + hideUserInfo?: boolean; }; @@ -21,12 +18,11 @@ const ContentText = ({ message, hideUserInfo = false, }: ContentTextProps) => { - // console.log('123', message); const formattedTime = formatChatTime( message.common.threadDateTime, hideUserInfo, ); - console.log('messages!', message); + // console.log('messages!', message); return (
diff --git a/src/frontend/apps/web/src/features/chat/ui/file-modal.tsx b/src/frontend/apps/web/src/features/chat/ui/file-modal.tsx index b877072d..51f24d8e 100644 --- a/src/frontend/apps/web/src/features/chat/ui/file-modal.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/file-modal.tsx @@ -1,8 +1,9 @@ -import { Button } from '@workspace/ui/components'; -import { Modal } from '@workspace/ui/components/Modal/modal'; +import Image from 'next/image'; + +import { Button, Modal } from '@workspace/ui/components'; + import { FilePreview } from '../model'; import { formatFileSize } from '../lib'; -import Image from 'next/image'; type FileModalProps = { isOpen: boolean; diff --git a/src/frontend/apps/web/src/features/workspace/api/create-channel.api.ts b/src/frontend/apps/web/src/features/workspace/api/create-channel.api.ts new file mode 100644 index 00000000..a64427c6 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/api/create-channel.api.ts @@ -0,0 +1,26 @@ +import { serverFetchInstance, TAG_KEYS } from '@/src/shared/services'; + +export type WorkspaceResponse = { + channelId: number; + channelName: string; + createdAt: Date; +}; + +export const createWorkspace = async ( + workspaceId: number, + channelName: string, +) => { + return serverFetchInstance( + `/api/v1/workspace/${workspaceId}/channels`, + 'POST', + { + includeAuthToken: true, + params: { + channelName: channelName, + }, + cache: 'force-cache', + revalidate: 300, + tags: [`${TAG_KEYS.WORKSPACE_CHANNEL(workspaceId)}`], + }, + ); +}; diff --git a/src/frontend/apps/web/src/features/workspace/api/get-workspace-list.api.ts b/src/frontend/apps/web/src/features/workspace/api/get-workspace-list.api.ts new file mode 100644 index 00000000..799022ea --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/api/get-workspace-list.api.ts @@ -0,0 +1,32 @@ +import { serverFetchInstance, TAG_KEYS } from '@/src/shared/services'; + +export type joinChannelResponse = { + channelId: number; + channelName: string; + createdAt: Date; + unreadNum?: number; +}; + +export type unjoinChannelResponse = { + channelId?: number; + channelName?: string; + createdAt?: Date; +}; + +export type WorkspaceListResponse = { + joinedChannels: joinChannelResponse[]; + unjoinedChannels: unjoinChannelResponse[]; +}; + +export const getWorkspaceList = async (workspaceId: number) => { + return serverFetchInstance( + `/api/v1/workspace/${workspaceId}/channels`, + 'GET', + { + includeAuthToken: true, + cache: 'force-cache', + revalidate: 300, + tags: [`${TAG_KEYS.WORKSPACE_CHANNEL(workspaceId)}`], + }, + ); +}; diff --git a/src/frontend/apps/web/src/features/workspace/api/get-workspace-page-props.ts b/src/frontend/apps/web/src/features/workspace/api/get-workspace-page-props.ts new file mode 100644 index 00000000..135fe016 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/api/get-workspace-page-props.ts @@ -0,0 +1,29 @@ +import type { GetServerSideProps } from 'next'; +import { QueryClient, dehydrate } from '@tanstack/react-query'; + +import { QUERY_KEYS } from '@/src/shared'; +import { getWorkspaceList } from '../api'; + +export type WorkspacePageProps = { + workspaceId: number; +}; + +export const getWorkspacePageProps: GetServerSideProps< + WorkspacePageProps +> = async (context) => { + const { workspaceId } = context.params as { workspaceId: string }; + const queryClient = new QueryClient(); + + const queryKey = QUERY_KEYS.workspaceList(Number(workspaceId)); + await queryClient.prefetchQuery({ + queryKey, + queryFn: () => getWorkspaceList(Number(workspaceId)), + }); + + return { + props: { + workspaceId: Number(workspaceId), + dehydratedState: dehydrate(queryClient), + }, + }; +}; diff --git a/src/frontend/apps/web/src/features/workspace/api/index.ts b/src/frontend/apps/web/src/features/workspace/api/index.ts new file mode 100644 index 00000000..4aa839e4 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/api/index.ts @@ -0,0 +1,7 @@ +export { getWorkspaceList } from './get-workspace-list.api'; +export type { + joinChannelResponse, + unjoinChannelResponse, + WorkspaceListResponse, +} from './get-workspace-list.api'; +export { createWorkspace } from './create-channel.api'; diff --git a/src/frontend/apps/web/src/features/workspace/lib/get-workspace-id.ts b/src/frontend/apps/web/src/features/workspace/lib/get-workspace-id.ts new file mode 100644 index 00000000..f4566f5b --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/lib/get-workspace-id.ts @@ -0,0 +1,18 @@ +import { WORKSPACE_ID } from '../model'; + +export function getWorkspaceId(stockSlug: string): number { + switch (stockSlug) { + case 'samsung-electronics': + return WORKSPACE_ID.SAMSUNG; + case 'sk-hynix': + return WORKSPACE_ID.SK_HYNIX; + case 'kakao': + return WORKSPACE_ID.KAKAO; + case 'naver': + return WORKSPACE_ID.NAVER; + case 'hanwha-aerospace': + return WORKSPACE_ID.HWANHWA; + default: + return -1; + } +} diff --git a/src/frontend/apps/web/src/features/workspace/lib/index.ts b/src/frontend/apps/web/src/features/workspace/lib/index.ts new file mode 100644 index 00000000..0a104178 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/lib/index.ts @@ -0,0 +1 @@ +export { getWorkspaceId } from './get-workspace-id'; diff --git a/src/frontend/apps/web/src/features/workspace/model/index.ts b/src/frontend/apps/web/src/features/workspace/model/index.ts new file mode 100644 index 00000000..9191364b --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/model/index.ts @@ -0,0 +1,6 @@ +export { WORKSPACE_ID } from './workspace-id'; +export { WorkspaceFormSchema } from './workspace-form-schema'; +export type { WorkspaceSubscriptionResponse } from './subscription.type'; +export { useWorkspaceMessages } from './use-workspace-messages'; +export { useWorkspaceSubscription } from './use-workspace-subscription'; +export { useWorkspaceChannels } from './use-workspace-channels'; diff --git a/src/frontend/apps/web/src/features/workspace/model/subscription.type.ts b/src/frontend/apps/web/src/features/workspace/model/subscription.type.ts new file mode 100644 index 00000000..b5e417a2 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/model/subscription.type.ts @@ -0,0 +1,7 @@ +export type WorkspaceSubscriptionResponse = { + workspaceId: number; + createUserId: number; + channelId: number; + channelName: string; + createdAt: Date; +}; diff --git a/src/frontend/apps/web/src/features/workspace/model/use-workspace-channels.tsx b/src/frontend/apps/web/src/features/workspace/model/use-workspace-channels.tsx new file mode 100644 index 00000000..3954b770 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/model/use-workspace-channels.tsx @@ -0,0 +1,112 @@ +import { useEffect, useReducer } from 'react'; + +import { useQuery } from '@tanstack/react-query'; + +import { QUERY_KEYS } from '@/src/shared'; + +import { + getWorkspaceList, + WorkspaceListResponse, + joinChannelResponse, + unjoinChannelResponse, +} from '../api'; +import { useWorkspaceMessages, useWorkspaceSubscription } from '../model'; + +type Action = + | { type: 'SET_INITIAL_DATA'; payload: WorkspaceListResponse } + | { type: 'ADD_JOINED_CHANNEL'; payload: joinChannelResponse } + | { type: 'ADD_UNJOINED_CHANNEL'; payload: unjoinChannelResponse }; + +type State = { + joinedChannels: joinChannelResponse[]; + unjoinedChannels: unjoinChannelResponse[]; +}; + +const initialState: State = { + joinedChannels: [], + unjoinedChannels: [], +}; + +const workspaceReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'SET_INITIAL_DATA': + return { + joinedChannels: action.payload.joinedChannels || [], + unjoinedChannels: action.payload.unjoinedChannels || [], + }; + + case 'ADD_JOINED_CHANNEL': + return { + ...state, + joinedChannels: [...state.joinedChannels, action.payload], + }; + + case 'ADD_UNJOINED_CHANNEL': + return { + ...state, + unjoinedChannels: [...state.unjoinedChannels, action.payload], + }; + + default: + return state; + } +}; + +export const useWorkspaceChannels = (workspaceId: number) => { + const { subscribe } = useWorkspaceSubscription(workspaceId); + const { data: workspaceSocketMessage } = useWorkspaceMessages(workspaceId); + + const [state, dispatch] = useReducer(workspaceReducer, initialState); + + const { + data: workspaceData, + isLoading, + error, + } = useQuery({ + queryKey: QUERY_KEYS.workspaceList(workspaceId), + queryFn: () => getWorkspaceList(workspaceId), + enabled: workspaceId !== -1, + staleTime: 60 * 1000, + }); + + useEffect(() => { + const unsubscribe = subscribe(); + return () => { + unsubscribe && unsubscribe(); + }; + }, [subscribe]); + + useEffect(() => { + if (!workspaceData) return; + dispatch({ type: 'SET_INITIAL_DATA', payload: workspaceData }); + }, [workspaceData]); + + useEffect(() => { + if (!workspaceSocketMessage) return; + + const userString = localStorage.getItem('user'); + if (!userString) return; + try { + const user = JSON.parse(userString); + const userId = user.userId; + if (!userId) return; + + const isUserCreator = workspaceSocketMessage.createUserId === userId; + if (isUserCreator) { + dispatch({ + type: 'ADD_JOINED_CHANNEL', + payload: workspaceSocketMessage, + }); + } else { + dispatch({ + type: 'ADD_UNJOINED_CHANNEL', + payload: workspaceSocketMessage, + }); + } + } catch (error) { + console.error('Failed to parse user data:', error); + } + }, [workspaceSocketMessage]); + + return { ...state, isLoading, error }; +}; diff --git a/src/frontend/apps/web/src/features/workspace/model/use-workspace-messages.ts b/src/frontend/apps/web/src/features/workspace/model/use-workspace-messages.ts new file mode 100644 index 00000000..916fba7e --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/model/use-workspace-messages.ts @@ -0,0 +1,21 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import { QUERY_KEYS } from '@/src/shared/services'; + +import type { WorkspaceSubscriptionResponse } from './subscription.type'; + +export const useWorkspaceMessages = (workspaceId: number) => { + const queryClient = useQueryClient(); + const queryKey = QUERY_KEYS.workspaceMessages(workspaceId); + + const { data: message } = useQuery({ + queryKey, + initialData: () => + queryClient.getQueryData( + queryKey, + ) ?? null, + staleTime: Infinity, + }); + + return { data: message }; +}; diff --git a/src/frontend/apps/web/src/features/workspace/model/use-workspace-subscription.ts b/src/frontend/apps/web/src/features/workspace/model/use-workspace-subscription.ts new file mode 100644 index 00000000..ecc1d2fe --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/model/use-workspace-subscription.ts @@ -0,0 +1,61 @@ +// useWorkspaceSubscription.ts +import { useCallback, useEffect, useState } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { useStompWebSocket } from '@/src/shared/providers'; +import { QUERY_KEYS } from '@/src/shared/services'; + +import type { WorkspaceSubscriptionResponse } from './subscription.type'; + +export const useWorkspaceSubscription = (workspaceId: number) => { + const queryClient = useQueryClient(); + const { client } = useStompWebSocket(); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + if (client && client.connected) { + setIsConnected(true); + } + }, [client]); + + const subscribe = useCallback(() => { + if (!client) { + console.error('❌ WebSocket Client가 없습니다.'); + return; + } + if (!client.connected) { + console.warn( + '⏳ WebSocket이 아직 연결되지 않았습니다. 구독을 대기합니다.', + ); + return; + } + + const subscription = client.subscribe( + `/subscribe/workspace.${workspaceId}`, + (message) => { + try { + const payload = JSON.parse( + message.body, + ) as WorkspaceSubscriptionResponse; + console.log('📩 Received workspace message:', payload); + console.log('workspace success'); + + queryClient.setQueryData( + QUERY_KEYS.workspaceMessages(workspaceId), + payload, + ); + } catch (error) { + console.error('❌ 메시지 파싱 실패:', error); + } + }, + ); + + return () => { + console.log(`📴 Unsubscribing from /subscribe/workspace.${workspaceId}`); + subscription.unsubscribe(); + }; + }, [client, workspaceId, queryClient]); + + return { subscribe, isConnected }; +}; diff --git a/src/frontend/apps/web/src/features/workspace/model/workspace-form-schema.ts b/src/frontend/apps/web/src/features/workspace/model/workspace-form-schema.ts new file mode 100644 index 00000000..f6fc52b9 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/model/workspace-form-schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const WorkspaceFormSchema = z.object({ + workspace: z + .string() + .min(2, { message: '워크스페이스 이름은 2글자 이상이어야 합니다.' }) + .max(30, { message: '워크스페이스 이름은 30글자 이하여야 합니다.' }), +}); diff --git a/src/frontend/apps/web/src/features/workspace/model/workspace-id.ts b/src/frontend/apps/web/src/features/workspace/model/workspace-id.ts new file mode 100644 index 00000000..909b5190 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/model/workspace-id.ts @@ -0,0 +1,7 @@ +export const WORKSPACE_ID: Record = { + SAMSUNG: 1, + SK_HYNIX: 2, + KAKAO: 3, + NAVER: 4, + HWANHWA: 5, +} as const; diff --git a/src/frontend/apps/web/src/features/workspace/ui/sidebar-container.tsx b/src/frontend/apps/web/src/features/workspace/ui/sidebar-container.tsx index 01af0779..c58d7322 100644 --- a/src/frontend/apps/web/src/features/workspace/ui/sidebar-container.tsx +++ b/src/frontend/apps/web/src/features/workspace/ui/sidebar-container.tsx @@ -1,3 +1,9 @@ +'use client'; + +import { useState } from 'react'; + +import { ChevronRight, CirclePlus } from 'lucide-react'; + import { Collapsible, CollapsibleContent, @@ -12,59 +18,113 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@workspace/ui/components'; -import { ChevronRight } from 'lucide-react'; +import { useChatId } from '@/src/shared'; + +import WorkspaceModal from './workspace-modal'; + +import { useWorkspaceChannels } from '../model'; +import { createWorkspace } from '../api'; +import { getWorkspaceId } from '../lib'; + +const renderChannels = ( + channels: any[] | undefined, + onChannelClick: (channelId: number) => void, +) => { + if (!channels || channels.length === 0) { + return ( +
채널이 없습니다
+ ); + } + + return channels.map((channel) => ( + + onChannelClick(channel.channelId)} + > + {channel.channelName} + + + )); +}; const SidebarContainer = ({ stockSlug }: { stockSlug: string }) => { - const data = { - navMain: [ - { - title: '참여한 채널', - items: [ - { - title: 'one', - url: '#', - }, - { - title: 'two', - url: '#', - isActive: true, - }, - ], - }, - { - title: '전체 채널', - items: [ - { - title: 'three', - url: '#', - }, - { - title: 'four', - url: '#', - }, - ], - }, - ], + const workspaceId = getWorkspaceId(stockSlug); + const { setChannelId } = useChatId(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { joinedChannels, unjoinedChannels, isLoading, error } = + useWorkspaceChannels(workspaceId); + + if (error) return
Error: {String(error)}
; + if (workspaceId === -1) return
Error: Invalid workspace id
; + + // console.log('workspaceData:', workspaceData); + + const handleCreateChannel = async (data: { workspace?: string }) => { + await createWorkspace(workspaceId, data.workspace); }; + + const handleChannelClick = (channelId: number) => { + setChannelId(channelId); + + const chatData = { + workspace: workspaceId, + channelId: channelId, + }; + + localStorage.setItem('chat', JSON.stringify(chatData)); + }; + return ( - - -
-
- Workspace -
-
- {stockSlug} + <> + + +
+
+ Workspace +
+
+ {stockSlug} +
-
- - - {data.navMain.map((item) => ( + + + + + + + 마이 채널 + + + + + + + {isLoading ? ( +
+ 로딩 중... +
+ ) : ( + renderChannels(joinedChannels, handleChannelClick) + )} +
+
+
+
+
+ + {/* 두 번째 독립적인 Collapsible */} { className="group/label text-sm text-white/90 hover:bg-white/10 hover:text-white transition-colors px-4 py-2" > - {item.title} - + 다른 채널 + - {item.items.map((item) => ( - - - {item.title} - - - ))} + {isLoading ? ( +
+ 로딩 중... +
+ ) : ( + renderChannels(unjoinedChannels, handleChannelClick) + )}
- ))} -
- + + + + setIsModalOpen(true)}> + 채널 생성하기 + + + + + +
+ + + ); }; + export default SidebarContainer; diff --git a/src/frontend/apps/web/src/features/workspace/ui/workspace-modal.tsx b/src/frontend/apps/web/src/features/workspace/ui/workspace-modal.tsx new file mode 100644 index 00000000..e0c65577 --- /dev/null +++ b/src/frontend/apps/web/src/features/workspace/ui/workspace-modal.tsx @@ -0,0 +1,137 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@workspace/ui/components'; + +import { WorkspaceFormSchema } from '../model'; + +type FormValues = z.infer; + +type WorkspaceModalProps = { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + size?: 'default' | 'lg'; + className?: string; + onSubmit?: (data: FormValues) => void; +}; + +const WorkspaceModal = ({ + isOpen, + setIsOpen, + className = '', + onSubmit: externalSubmit, +}: WorkspaceModalProps) => { + const form = useForm({ + resolver: zodResolver(WorkspaceFormSchema), + defaultValues: { workspace: '' }, + }); + + const handleSubmit = (values: FormValues) => { + if (externalSubmit) { + externalSubmit(values); + } else { + console.log(values); + } + setIsOpen(false); + }; + + useEffect(() => { + form.setError('workspace', { + type: 'manual', + message: '워크스페이스명은 2글자 이상이어야 합니다.', + }); + }, [form]); + + const [showModal, setShowModal] = useState(isOpen); + const [animate, setAnimate] = useState(false); + + useEffect(() => { + if (isOpen) { + setShowModal(true); + + setTimeout(() => setAnimate(true), 20); + } else { + setAnimate(false); + + const timer = setTimeout(() => setShowModal(false), 300); + return () => clearTimeout(timer); + } + }, [isOpen]); + + if (!showModal) return null; + + return ( + <> +
setIsOpen(false)} + /> + +
+
e.stopPropagation()} + > +
+ + ( + + 워크스페이스 이름 + + + + + 워크스페이스 채널 리스트에 표시될 이름입니다. + + + + )} + /> + +
+ + +
+ + +
+
+ + ); +}; + +export default WorkspaceModal; diff --git a/src/frontend/apps/web/src/shared/hooks/.keep b/src/frontend/apps/web/src/shared/hooks/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/frontend/apps/web/src/shared/index.ts b/src/frontend/apps/web/src/shared/index.ts index 932b02d4..2d55cc73 100644 --- a/src/frontend/apps/web/src/shared/index.ts +++ b/src/frontend/apps/web/src/shared/index.ts @@ -1,2 +1,3 @@ export * from './components'; export * from './providers'; +export * from './services'; diff --git a/src/frontend/apps/web/src/shared/providers/chat-id-provider.tsx b/src/frontend/apps/web/src/shared/providers/chat-id-provider.tsx new file mode 100644 index 00000000..9f219f5f --- /dev/null +++ b/src/frontend/apps/web/src/shared/providers/chat-id-provider.tsx @@ -0,0 +1,75 @@ +'use client'; +import { createContext, useContext, useEffect, useState } from 'react'; + +import { getWorkspaceId } from '@/src/features/workspace/lib'; + +type ChatContextType = { + workspaceId: number | null; + channelId: number | null; + setChannelId: (channelId: number) => void; +}; + +const ChatContext = createContext(undefined); + +export const ChatIdProvider = ({ + children, + stockSlug, +}: { + children: React.ReactNode; + stockSlug: string; +}) => { + const [workspaceId, setWorkspaceId] = useState(null); + const [channelId, setChannelId] = useState(null); + + useEffect(() => { + const storedChat = localStorage.getItem('chat'); + const newWorkspaceId = getWorkspaceId(stockSlug); + + if (storedChat) { + try { + const chatData = JSON.parse(storedChat); + + if (chatData.workspace !== newWorkspaceId) { + setWorkspaceId(newWorkspaceId); + setChannelId(null); + localStorage.setItem( + 'chat', + JSON.stringify({ workspace: newWorkspaceId, channelId: null }), + ); + } else { + setWorkspaceId(chatData.workspace); + setChannelId(chatData.channelId); + } + } catch (error) { + console.error('Failed to parse chat data:', error); + setWorkspaceId(newWorkspaceId); + setChannelId(null); + } + } else { + setWorkspaceId(newWorkspaceId); + setChannelId(null); + } + }, [stockSlug]); + + const updateChannelId = (channelId: number) => { + setChannelId(channelId); + const chatData = { workspace: workspaceId, channelId }; + localStorage.setItem('chat', JSON.stringify(chatData)); + }; + + return ( + + {children} + + ); +}; + +export const useChatId = () => { + const context = useContext(ChatContext); + if (!context) { + throw new Error('useChat must be used within a ChatProvider'); + } + return context; +}; diff --git a/src/frontend/apps/web/src/shared/providers/index.ts b/src/frontend/apps/web/src/shared/providers/index.ts index 924a1bd5..1580a353 100644 --- a/src/frontend/apps/web/src/shared/providers/index.ts +++ b/src/frontend/apps/web/src/shared/providers/index.ts @@ -4,4 +4,5 @@ export { useStompWebSocket, } from './stomp-websocket-provider'; export { default as RQProvider } from './rq-provider'; +export { ChatIdProvider, useChatId } from './chat-id-provider'; export { WebSocketProvider, useWebSocket } from './websocket-provider'; diff --git a/src/frontend/apps/web/src/shared/services/apis/client-fetch.api.ts b/src/frontend/apps/web/src/shared/services/apis/client-fetch.api.ts index 5ed43513..4ba01de0 100644 --- a/src/frontend/apps/web/src/shared/services/apis/client-fetch.api.ts +++ b/src/frontend/apps/web/src/shared/services/apis/client-fetch.api.ts @@ -1,18 +1,18 @@ 'use client'; -import type { - ApiServerType, - FetchOptions, - JsonValue, -} from '@/src/shared/services/models'; -import { getAccessTokenFromCookie } from '../lib'; +import type { FetchOptions, JsonValue } from '@/src/shared/services/models'; import { Fetch } from './fetch.api'; -const getClientToken = async (): Promise => { - const match = await getAccessTokenFromCookie(); - return match; - // return process.env.NEXT_PUBLIC_TEST_TOKEN; +export const getClientToken = async (): Promise => { + try { + const response = await fetch('/api/auth/token', { credentials: 'include' }); // API 호출 + const data = await response.json(); + return data.token || undefined; + } catch (error) { + console.error('Failed to fetch token:', error); + return undefined; + } }; export const clientFetchInstance = async ( diff --git a/src/frontend/apps/web/src/shared/services/apis/index.ts b/src/frontend/apps/web/src/shared/services/apis/index.ts index 8dca7499..97b2f9d2 100644 --- a/src/frontend/apps/web/src/shared/services/apis/index.ts +++ b/src/frontend/apps/web/src/shared/services/apis/index.ts @@ -6,6 +6,7 @@ export { deleteRequest, } from './fetch-method.api'; export { QUERY_KEYS } from './querykey'; +export { TAG_KEYS } from './tagkey'; export { clientFetchInstance } from './client-fetch.api'; export { serverFetchInstance } from './server-fetch.api'; export { Fetch } from './fetch.api'; diff --git a/src/frontend/apps/web/src/shared/services/apis/querykey.ts b/src/frontend/apps/web/src/shared/services/apis/querykey.ts index 53ca3649..74d51252 100644 --- a/src/frontend/apps/web/src/shared/services/apis/querykey.ts +++ b/src/frontend/apps/web/src/shared/services/apis/querykey.ts @@ -3,4 +3,8 @@ export const QUERY_KEYS = { ['messages', `/subscribe/chat.${channelId}`] as const, forwardHistory: (channelId: number) => ['forwardHistory', channelId] as const, reverseHistory: (channelId: number) => ['reverseHistory', channelId] as const, + workspaceList: (workspaceId: number) => + ['workspaceList', workspaceId] as const, + workspaceMessages: (workspaceId: number) => + ['workspaceMessages', `/subscribe/workspace.${workspaceId}`] as const, }; diff --git a/src/frontend/apps/web/src/shared/services/apis/server-fetch.api.ts b/src/frontend/apps/web/src/shared/services/apis/server-fetch.api.ts index 3bc5a162..3bda38f3 100644 --- a/src/frontend/apps/web/src/shared/services/apis/server-fetch.api.ts +++ b/src/frontend/apps/web/src/shared/services/apis/server-fetch.api.ts @@ -1,11 +1,7 @@ 'use server'; import { cookies } from 'next/headers'; -import type { - ApiServerType, - FetchOptions, - JsonValue, -} from '@/src/shared/services/models'; +import type { FetchOptions, JsonValue } from '@/src/shared/services/models'; import { Fetch } from './fetch.api'; export async function serverFetchInstance( diff --git a/src/frontend/apps/web/src/shared/services/apis/tagkey.ts b/src/frontend/apps/web/src/shared/services/apis/tagkey.ts new file mode 100644 index 00000000..5d67b637 --- /dev/null +++ b/src/frontend/apps/web/src/shared/services/apis/tagkey.ts @@ -0,0 +1,6 @@ +export const TAG_KEYS = { + WORKSPACE_CHANNEL: (workspaceId: number) => + ['workspace-channels', `workspace-${workspaceId}`] as const, + CHAT_HISTORY: (channelId: number) => + ['chat-history', `channel-${channelId}`] as const, +}; diff --git a/src/frontend/apps/web/src/shared/services/index.ts b/src/frontend/apps/web/src/shared/services/index.ts index 20339832..221641fc 100644 --- a/src/frontend/apps/web/src/shared/services/index.ts +++ b/src/frontend/apps/web/src/shared/services/index.ts @@ -1,2 +1,3 @@ export * from './apis'; export * from './models'; +export * from './lib'; diff --git a/src/frontend/package.json b/src/frontend/package.json index 2003a99b..bfc8f773 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -14,7 +14,6 @@ "devDependencies": { "@workspace/eslint-config": "workspace:*", "@workspace/typescript-config": "workspace:*", - "danger": "^12.3.3", "prettier": "^3.4.2", "turbo": "^2.3.3", "typescript": "5.7.3" diff --git a/src/frontend/packages/ui/package.json b/src/frontend/packages/ui/package.json index f22936ca..ef876776 100644 --- a/src/frontend/packages/ui/package.json +++ b/src/frontend/packages/ui/package.json @@ -9,6 +9,7 @@ "build-storybook": "storybook build -o storybook-static" }, "dependencies": { + "@hookform/resolvers": "^4.1.2", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.5", @@ -27,6 +28,7 @@ "next-themes": "^0.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-resizable-panels": "^2.1.7", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", diff --git a/src/frontend/packages/ui/src/components/Form/form.stories.tsx b/src/frontend/packages/ui/src/components/Form/form.stories.tsx new file mode 100644 index 00000000..d655bab5 --- /dev/null +++ b/src/frontend/packages/ui/src/components/Form/form.stories.tsx @@ -0,0 +1,289 @@ +import { useEffect } from 'react'; + +import { Meta, StoryObj } from '@storybook/react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import { Input } from '@workspace/ui/components/Input/input'; +import { Button } from '@workspace/ui/components/Button/button'; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from './form'; + +const formSchema = z.object({ + username: z + .string() + .min(2, { message: '사용자 이름은 2글자 이상이어야 합니다.' }) + .max(30, { message: '사용자 이름은 30글자 이하여야 합니다.' }), + email: z.string().email({ message: '유효한 이메일 주소를 입력해주세요.' }), +}); + +const meta: Meta = { + title: 'Widget/Form', + component: Form, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +// 기본 Form 예시 +export const Basic: Story = { + render: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + username: '', + email: '', + }, + }); + + const onSubmit = (values: z.infer) => { + console.log(values); + alert(JSON.stringify(values, null, 2)); + }; + + return ( +
+ + ( + + 사용자 이름 + + + + + 공개 프로필에 표시될 이름입니다. + + + + )} + /> + ( + + 이메일 + + + + + 알림을 받을 이메일 주소입니다. + + + + )} + /> + + + + ); + }, +}; + +// 에러 상태 Form 예시 +export const WithErrors: Story = { + render: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + username: '', + email: '', + }, + mode: 'onChange', + }); + + // 강제로 에러 표시 + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + form.setError('username', { + type: 'manual', + message: '사용자 이름은 2글자 이상이어야 합니다.', + }); + form.setError('email', { + type: 'manual', + message: '유효한 이메일 주소를 입력해주세요.', + }); + }, [form]); + + const onSubmit = (values: z.infer) => { + console.log(values); + alert(JSON.stringify(values, null, 2)); + }; + + return ( +
+ + ( + + 사용자 이름 + + + + + 공개 프로필에 표시될 이름입니다. + + + + )} + /> + ( + + 이메일 + + + + + 알림을 받을 이메일 주소입니다. + + + + )} + /> + + + + ); + }, +}; + +// 다양한 폼 요소 예시 +export const ComplexForm: Story = { + render: () => { + // 복잡한 폼 스키마 정의 + const complexSchema = z.object({ + name: z.string().min(2, { message: '이름은 2글자 이상이어야 합니다.' }), + email: z + .string() + .email({ message: '유효한 이메일 주소를 입력해주세요.' }), + bio: z + .string() + .max(160, { message: '자기소개는 160자 이내로 작성해주세요.' }) + .optional(), + }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm>({ + resolver: zodResolver(complexSchema), + defaultValues: { + name: '', + email: '', + bio: '', + }, + }); + + const onSubmit = (values: z.infer) => { + console.log(values); + alert(JSON.stringify(values, null, 2)); + }; + + return ( +
+ + ( + + 이름 + + + + + + )} + /> + ( + + 이메일 + + + + + + )} + /> + ( + + 자기소개 + +