diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..725b4574 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "Bash(tree:*)", + "Bash(find:*)", + "Bash(git mv:*)", + "Bash(wc:*)", + "Bash(npx tsc:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ] + } +} diff --git a/.github/workflows/expo-deploy.yml b/.github/workflows/expo-deploy.yml new file mode 100644 index 00000000..101851f6 --- /dev/null +++ b/.github/workflows/expo-deploy.yml @@ -0,0 +1,140 @@ +name: Deploy to EC2 and Notify Slack + +on: + push: + branches: + - native + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + env: + REPO_PATH: /home/ubuntu/Pointer + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + envs: REPO_PATH + script: | + cd $REPO_PATH + echo "Pulling latest changes..." + git pull origin native + + echo "Installing dependencies..." + cd apps/native + npm install + pm2 restart expo + + echo "✅ Deployment completed!" + + - name: Prepare commit message + if: always() + id: commit + run: | + COMMIT_MSG=$(echo '${{ github.event.head_commit.message }}' | head -n 1 | sed 's/"/\\"/g' | sed "s/'/\\'/g") + echo "message=$COMMIT_MSG" >> $GITHUB_OUTPUT + + - name: Notify Slack - Success + if: success() + uses: slackapi/slack-github-action@v1.26.0 + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + with: + payload: | + { + "text": "✅ Expo Deployment Success", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "✅ Expo Deployment Successful" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:* ${{ github.repository }}" + }, + { + "type": "mrkdwn", + "text": "*Branch:* ${{ github.ref_name }}" + }, + { + "type": "mrkdwn", + "text": "*Author:* ${{ github.event.head_commit.author.name }}" + }, + { + "type": "mrkdwn", + "text": "*Commit:* ${{ steps.commit.outputs.message }}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "<${{ github.event.head_commit.url }}|View Commit>" + } + } + ] + } + + - name: Notify Slack - Failure + if: failure() + uses: slackapi/slack-github-action@v1.26.0 + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + with: + payload: | + { + "text": "❌ Expo Deployment Failed", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "❌ Expo Deployment Failed" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:* ${{ github.repository }}" + }, + { + "type": "mrkdwn", + "text": "*Branch:* ${{ github.ref_name }}" + }, + { + "type": "mrkdwn", + "text": "*Author:* ${{ github.event.head_commit.author.name }}" + }, + { + "type": "mrkdwn", + "text": "*Commit:* ${{ steps.commit.outputs.message }}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "⚠️ Check the <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|workflow logs>" + } + } + ] + } diff --git a/.npmrc b/.npmrc index e69de29b..ed8ff3f3 100644 --- a/.npmrc +++ b/.npmrc @@ -0,0 +1,2 @@ +@team-ppointer:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NPM_TOKEN} \ No newline at end of file diff --git a/apps/admin/package.json b/apps/admin/package.json index 82352af7..f8fc6d3a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -10,7 +10,7 @@ "serve": "vite preview", "start": "vite", "lint": "eslint .", - "openapi": "pnpm dlx openapi-typescript https://api.math-pointer.com/v3/api-docs --output ./src/types/api/schema.d.ts && prettier --write ./src/types/api/schema.d.ts" + "openapi": "pnpm dlx openapi-typescript https://dev.api.math-pointer.com/v3/api-docs --output ./src/types/api/schema.d.ts && prettier --write ./src/types/api/schema.d.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -23,12 +23,15 @@ "@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-router": "^1.98.4", "@tanstack/router-devtools": "^1.98.4", + "@team-ppointer/pointer-editor-v2": "^2.3.0", "clsx": "^2.1.1", "dayjs": "^1.11.13", "immer": "^10.1.1", "lodash": "^4.17.21", + "lucide-react": "^0.553.0", "openapi-fetch": "^0.13.4", "openapi-react-query": "^0.3.0", + "progressive-blur": "^1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^14.3.5", diff --git a/apps/admin/src/apis/controller/notification/getNotification.ts b/apps/admin/src/apis/controller/notification/getNotification.ts new file mode 100644 index 00000000..e2fdcff9 --- /dev/null +++ b/apps/admin/src/apis/controller/notification/getNotification.ts @@ -0,0 +1,12 @@ +import { $api } from '@apis'; +import { GetNotificationParams } from '@types'; + +const getNotification = (params: GetNotificationParams) => { + return $api.useQuery('get', '/api/admin/notification', { + params: { + query: params, + }, + }); +}; + +export default getNotification; diff --git a/apps/admin/src/apis/controller/notification/index.ts b/apps/admin/src/apis/controller/notification/index.ts new file mode 100644 index 00000000..88c6dbd1 --- /dev/null +++ b/apps/admin/src/apis/controller/notification/index.ts @@ -0,0 +1,4 @@ +import getNotification from './getNotification'; +import postNotification from './postNotification'; + +export { getNotification, postNotification }; diff --git a/apps/admin/src/apis/controller/notification/postNotification.ts b/apps/admin/src/apis/controller/notification/postNotification.ts new file mode 100644 index 00000000..a4033b56 --- /dev/null +++ b/apps/admin/src/apis/controller/notification/postNotification.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const postNotification = () => { + return $api.useMutation('post', '/api/admin/notification/send'); +}; + +export default postNotification; diff --git a/apps/admin/src/apis/controller/practiceTest/postPracticeTest.ts b/apps/admin/src/apis/controller/practiceTest/postPracticeTest.ts index af888dc6..8575be58 100644 --- a/apps/admin/src/apis/controller/practiceTest/postPracticeTest.ts +++ b/apps/admin/src/apis/controller/practiceTest/postPracticeTest.ts @@ -4,4 +4,4 @@ const postPracticeTest = () => { return $api.useMutation('post', '/api/admin/practice-test'); }; -export default postPracticeTest; \ No newline at end of file +export default postPracticeTest; diff --git a/apps/admin/src/apis/controller/practiceTest/putPracticeTest.ts b/apps/admin/src/apis/controller/practiceTest/putPracticeTest.ts index a3fbece4..bed16998 100644 --- a/apps/admin/src/apis/controller/practiceTest/putPracticeTest.ts +++ b/apps/admin/src/apis/controller/practiceTest/putPracticeTest.ts @@ -4,4 +4,4 @@ const putPracticeTest = () => { return $api.useMutation('put', '/api/admin/practice-test/{id}'); }; -export default putPracticeTest; \ No newline at end of file +export default putPracticeTest; diff --git a/apps/admin/src/apis/controller/problemSet/index.ts b/apps/admin/src/apis/controller/problemSet/index.ts index acd223df..9e830aa9 100644 --- a/apps/admin/src/apis/controller/problemSet/index.ts +++ b/apps/admin/src/apis/controller/problemSet/index.ts @@ -4,7 +4,6 @@ import getProblemSetById from './getProblemSetById'; import getProblemSetSearch from './getProblemSetSearch'; import postProblemSet from './postProblemSet'; import putProblemSet from './putProblemSet'; -import putProblemSetStatus from './putProblemSetStatus'; import putProblemSetToggleStatus from './putProblemSetToggleStatus'; export { @@ -14,6 +13,5 @@ export { getProblemSetSearch, postProblemSet, putProblemSet, - putProblemSetStatus, putProblemSetToggleStatus, }; diff --git a/apps/admin/src/apis/controller/problemSet/putProblemSetStatus.ts b/apps/admin/src/apis/controller/problemSet/putProblemSetStatus.ts deleted file mode 100644 index 597bbfb3..00000000 --- a/apps/admin/src/apis/controller/problemSet/putProblemSetStatus.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { $api } from '@apis'; - -const putProblemSetStatus = () => { - return $api.useMutation('put', '/api/admin/problem-set/{id}/status'); -}; - -export default putProblemSetStatus; diff --git a/apps/admin/src/apis/controller/qna/deleteQnaChat.ts b/apps/admin/src/apis/controller/qna/deleteQnaChat.ts new file mode 100644 index 00000000..b208a75d --- /dev/null +++ b/apps/admin/src/apis/controller/qna/deleteQnaChat.ts @@ -0,0 +1,8 @@ +import { $api } from '@apis'; + +const deleteQnaChat = () => { + return $api.useMutation('delete', '/api/admin/qna/chat/{chatId}'); +}; + +export default deleteQnaChat; + diff --git a/apps/admin/src/apis/controller/qna/getQna.ts b/apps/admin/src/apis/controller/qna/getQna.ts new file mode 100644 index 00000000..a591d7d4 --- /dev/null +++ b/apps/admin/src/apis/controller/qna/getQna.ts @@ -0,0 +1,16 @@ +import { $api } from '@apis'; + +interface GetQnaParams { + query?: string; +} + +const getQna = (params?: GetQnaParams) => { + return $api.useQuery('get', '/api/admin/qna', { + params: { + query: params, + }, + }); +}; + +export default getQna; + diff --git a/apps/admin/src/apis/controller/qna/getQnaById.ts b/apps/admin/src/apis/controller/qna/getQnaById.ts new file mode 100644 index 00000000..d5d1e866 --- /dev/null +++ b/apps/admin/src/apis/controller/qna/getQnaById.ts @@ -0,0 +1,22 @@ +import { $api } from '@apis'; + +interface GetQnaByIdParams { + qnaId: number; + enabled?: boolean; +} + +const getQnaById = ({ qnaId, enabled = true }: GetQnaByIdParams) => { + return $api.useQuery( + 'get', + '/api/admin/qna/{qnaId}', + { + params: { + path: { qnaId }, + }, + }, + { enabled } + ); +}; + +export default getQnaById; + diff --git a/apps/admin/src/apis/controller/qna/index.ts b/apps/admin/src/apis/controller/qna/index.ts new file mode 100644 index 00000000..f7f5e4ed --- /dev/null +++ b/apps/admin/src/apis/controller/qna/index.ts @@ -0,0 +1,9 @@ +import deleteQnaChat from './deleteQnaChat'; +import getQna from './getQna'; +import getQnaById from './getQnaById'; +import postQnaChat from './postQnaChat'; +import putQnaChat from './putQnaChat'; +import useSubscribeQna from './useSubscribeQna'; + +export { deleteQnaChat, getQna, getQnaById, postQnaChat, putQnaChat, useSubscribeQna }; + diff --git a/apps/admin/src/apis/controller/qna/postQnaChat.ts b/apps/admin/src/apis/controller/qna/postQnaChat.ts new file mode 100644 index 00000000..ee07f60b --- /dev/null +++ b/apps/admin/src/apis/controller/qna/postQnaChat.ts @@ -0,0 +1,8 @@ +import { $api } from '@apis'; + +const postQnaChat = () => { + return $api.useMutation('post', '/api/admin/qna/chat'); +}; + +export default postQnaChat; + diff --git a/apps/admin/src/apis/controller/qna/putQnaChat.ts b/apps/admin/src/apis/controller/qna/putQnaChat.ts new file mode 100644 index 00000000..1986586b --- /dev/null +++ b/apps/admin/src/apis/controller/qna/putQnaChat.ts @@ -0,0 +1,8 @@ +import { $api } from '@apis'; + +const putQnaChat = () => { + return $api.useMutation('put', '/api/admin/qna/chat/{chatId}'); +}; + +export default putQnaChat; + diff --git a/apps/admin/src/apis/controller/qna/useSubscribeQna.ts b/apps/admin/src/apis/controller/qna/useSubscribeQna.ts new file mode 100644 index 00000000..f62cdd09 --- /dev/null +++ b/apps/admin/src/apis/controller/qna/useSubscribeQna.ts @@ -0,0 +1,349 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { components } from '@schema'; + +type QnAChatEvent = components['schemas']['QnAChatEvent']; +type QnAReadStatusEvent = components['schemas']['QnAReadStatusEvent']; + +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'; + +type SSEEventHandlers = { + onChatEvent?: (event: QnAChatEvent) => void; + onReadStatusEvent?: (event: QnAReadStatusEvent) => void; + onHeartbeat?: () => void; + onError?: (error: Error) => void; + onOpen?: () => void; + onConnectionStatusChange?: (status: ConnectionStatus) => void; +}; + +type ReconnectConfig = { + /** 최대 재시도 횟수 (기본값: 10) */ + maxRetries?: number; + /** 초기 재시도 간격 (ms, 기본값: 1000) */ + initialDelay?: number; + /** 최대 재시도 간격 (ms, 기본값: 30000) */ + maxDelay?: number; + /** 하트비트 타임아웃 (ms, 기본값: 60000 - 1분) */ + heartbeatTimeout?: number; +}; + +type UseSubscribeQnaOptions = { + qnaId: number; + token: string; + enabled?: boolean; + reconnectConfig?: ReconnectConfig; +} & SSEEventHandlers; + +const DEFAULT_RECONNECT_CONFIG: Required = { + maxRetries: 10, + initialDelay: 1000, + maxDelay: 30000, + heartbeatTimeout: 60000, +}; + +const useSubscribeQna = ({ + qnaId, + token, + enabled = true, + reconnectConfig, + onChatEvent, + onReadStatusEvent, + onHeartbeat, + onError, + onOpen, + onConnectionStatusChange, +}: UseSubscribeQnaOptions) => { + const config = { ...DEFAULT_RECONNECT_CONFIG, ...reconnectConfig }; + + const eventSourceRef = useRef(null); + const retryCountRef = useRef(0); + const retryTimeoutRef = useRef | null>(null); + const heartbeatTimeoutRef = useRef | null>(null); + const isManualDisconnectRef = useRef(false); + + // Refs for stable function references (to avoid circular dependencies) + const connectRef = useRef<() => void>(() => {}); + const scheduleReconnectRef = useRef<() => void>(() => {}); + + const [connectionStatus, setConnectionStatus] = useState('disconnected'); + + // 연결 상태 변경 핸들러 + const updateConnectionStatus = useCallback( + (status: ConnectionStatus) => { + setConnectionStatus(status); + onConnectionStatusChange?.(status); + }, + [onConnectionStatusChange] + ); + + // 재시도 지연 시간 계산 (지수 백오프) + const getRetryDelay = useCallback(() => { + const delay = Math.min( + config.initialDelay * Math.pow(2, retryCountRef.current), + config.maxDelay + ); + // 약간의 랜덤 지터 추가 (0.5 ~ 1.5 배) + return delay * (0.5 + Math.random()); + }, [config.initialDelay, config.maxDelay]); + + // 타이머 정리 + const clearTimers = useCallback(() => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + heartbeatTimeoutRef.current = null; + } + }, []); + + // 하트비트 타임아웃 리셋 + const resetHeartbeatTimeout = useCallback(() => { + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + } + heartbeatTimeoutRef.current = setTimeout(() => { + console.warn('[SSE] Heartbeat timeout - attempting reconnection'); + scheduleReconnectRef.current(); + }, config.heartbeatTimeout); + }, [config.heartbeatTimeout]); + + // 재연결 예약 + const scheduleReconnect = useCallback(() => { + if (isManualDisconnectRef.current) { + console.log('[SSE] Manual disconnect - skipping reconnect'); + return; + } + + if (retryCountRef.current >= config.maxRetries) { + console.error('[SSE] Max retry attempts reached'); + updateConnectionStatus('disconnected'); + onError?.(new Error('Max reconnection attempts reached')); + return; + } + + const delay = getRetryDelay(); + console.log( + `[SSE] Scheduling reconnect in ${Math.round(delay)}ms (attempt ${retryCountRef.current + 1}/${config.maxRetries})` + ); + updateConnectionStatus('reconnecting'); + + retryTimeoutRef.current = setTimeout(() => { + retryCountRef.current += 1; + connectRef.current(); + }, delay); + }, [config.maxRetries, getRetryDelay, updateConnectionStatus, onError]); + + // Update ref + scheduleReconnectRef.current = scheduleReconnect; + + // 연결 + const connect = useCallback(() => { + if (!enabled || !qnaId || !token) { + console.log('[SSE] Connection skipped - not enabled or missing params'); + return; + } + + // 기존 연결 종료 + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + clearTimers(); + isManualDisconnectRef.current = false; + updateConnectionStatus('connecting'); + + const baseUrl = import.meta.env.VITE_API_BASE_URL; + const url = `${baseUrl}/api/qna/${qnaId}/subscribe?token=${encodeURIComponent(token)}`; + + console.log('[SSE] Connecting to:', url); + + const es = new EventSource(url); + + // 연결 성공 + es.onopen = () => { + console.log('[SSE] Connection opened for QnA:', qnaId); + retryCountRef.current = 0; + updateConnectionStatus('connected'); + resetHeartbeatTimeout(); + onOpen?.(); + }; + + // 메시지 이벤트 (디버깅용) + es.onmessage = (event) => { + console.log('[SSE] Message event:', event.data); + resetHeartbeatTimeout(); + }; + + // 에러 핸들링 + es.onerror = (event) => { + console.error('[SSE] Error event:', event); + + if (!isManualDisconnectRef.current) { + onError?.(new Error('SSE connection error')); + es.close(); + eventSourceRef.current = null; + scheduleReconnectRef.current(); + } + }; + + // Chat 이벤트 (생성/수정/삭제) + es.addEventListener('chat', (event) => { + console.log('[SSE] Chat event:', event.data); + try { + if (event.data) { + const data = JSON.parse(event.data) as QnAChatEvent; + onChatEvent?.(data); + } + } catch (error) { + console.error('[SSE] Failed to parse chat event:', error); + } + resetHeartbeatTimeout(); + }); + + // 읽음 상태 이벤트 + es.addEventListener('read_status', (event) => { + console.log('[SSE] Read status event:', event.data); + try { + if (event.data) { + const data = JSON.parse(event.data) as QnAReadStatusEvent; + onReadStatusEvent?.(data); + } + } catch (error) { + console.error('[SSE] Failed to parse read_status event:', error); + } + resetHeartbeatTimeout(); + }); + + // 하트비트 이벤트 + es.addEventListener('heartbeat', () => { + console.log('[SSE] Heartbeat received'); + resetHeartbeatTimeout(); + onHeartbeat?.(); + }); + + eventSourceRef.current = es; + }, [ + enabled, + qnaId, + token, + clearTimers, + updateConnectionStatus, + resetHeartbeatTimeout, + onChatEvent, + onReadStatusEvent, + onHeartbeat, + onError, + onOpen, + ]); + + // Update ref + connectRef.current = connect; + + // 연결 해제 + const disconnect = useCallback(() => { + isManualDisconnectRef.current = true; + clearTimers(); + + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + console.log('[SSE] Connection closed for QnA:', qnaId); + } + + retryCountRef.current = 0; + updateConnectionStatus('disconnected'); + }, [qnaId, clearTimers, updateConnectionStatus]); + + // 수동 재연결 (재시도 카운트 리셋) + const reconnect = useCallback(() => { + console.log('[SSE] Manual reconnect requested'); + retryCountRef.current = 0; + isManualDisconnectRef.current = false; + connectRef.current(); + }, []); + + // 브라우저 visibility 변경 감지 + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && enabled && !isManualDisconnectRef.current) { + console.log('[SSE] Page became visible - checking connection'); + if (!eventSourceRef.current || eventSourceRef.current.readyState === EventSource.CLOSED) { + console.log('[SSE] Reconnecting...'); + retryCountRef.current = 0; + connectRef.current(); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled]); + + // 온라인/오프라인 상태 감지 + useEffect(() => { + const handleOnline = () => { + console.log('[SSE] Browser went online'); + if (enabled && !isManualDisconnectRef.current) { + retryCountRef.current = 0; + connectRef.current(); + } + }; + + const handleOffline = () => { + console.log('[SSE] Browser went offline'); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + updateConnectionStatus('disconnected'); + } + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, [enabled, updateConnectionStatus]); + + // 마운트 시 연결, 언마운트 시 해제 + useEffect(() => { + connectRef.current(); + + return () => { + disconnect(); + }; + }, [disconnect]); + + // enabled 또는 핵심 파라미터 변경 시 재연결 + useEffect(() => { + if (enabled && qnaId && token) { + connectRef.current(); + } else { + disconnect(); + } + }, [enabled, qnaId, token, disconnect]); + + return { + /** 수동 재연결 (재시도 카운트 리셋) */ + reconnect, + /** 연결 해제 */ + disconnect, + /** 현재 연결 상태 */ + connectionStatus, + /** 연결됨 여부 */ + isConnected: connectionStatus === 'connected', + /** 재연결 중 여부 */ + isReconnecting: connectionStatus === 'reconnecting', + }; +}; + +export default useSubscribeQna; +export type { ConnectionStatus, ReconnectConfig }; + diff --git a/apps/admin/src/apis/controller/student/index.ts b/apps/admin/src/apis/controller/student/index.ts index 8aab8f29..c748e8d4 100644 --- a/apps/admin/src/apis/controller/student/index.ts +++ b/apps/admin/src/apis/controller/student/index.ts @@ -1,3 +1,3 @@ import getStudent from './getStudent'; -export { getStudent }; \ No newline at end of file +export { getStudent }; diff --git a/apps/admin/src/apis/index.ts b/apps/admin/src/apis/index.ts index 55f72e4b..7fb406d1 100644 --- a/apps/admin/src/apis/index.ts +++ b/apps/admin/src/apis/index.ts @@ -7,11 +7,13 @@ export * from './controller/concept'; export * from './controller/diagnosis'; export * from './controller/file'; export * from './controller/notice'; +export * from './controller/notification'; export * from './controller/ocr'; export * from './controller/practiceTest'; export * from './controller/problem'; export * from './controller/problemSet'; export * from './controller/publish'; +export * from './controller/qna'; export * from './controller/student'; export * from './controller/teacher'; export * from './controller/user'; diff --git a/apps/admin/src/components/common/Buttons/Button.tsx b/apps/admin/src/components/common/Buttons/Button.tsx index ec36c07e..f636145d 100644 --- a/apps/admin/src/components/common/Buttons/Button.tsx +++ b/apps/admin/src/components/common/Buttons/Button.tsx @@ -2,41 +2,70 @@ import React from 'react'; import { ButtonHTMLAttributes } from 'react'; interface ButtonProps extends ButtonHTMLAttributes { - variant?: 'blue' | 'dark' | 'light' | 'dimmed'; - sizeType?: 'short' | 'long' | 'fit' | 'full'; + variant?: 'blue' | 'dark' | 'light' | 'dimmed' | 'primary' | 'secondary' | 'danger' | 'ghost'; + sizeType?: 'short' | 'long' | 'fit' | 'full' | 'sm' | 'md' | 'lg'; disabled?: boolean; children: React.ReactNode; } const Button = ({ variant = 'blue', - sizeType = 'short', + sizeType = 'fit', disabled = false, children, className, ...props }: ButtonProps) => { const baseStyles = - 'rounded-[0.8rem] font-14m-body flex items-center justify-center whitespace-nowrap break-keep'; + 'rounded-xl font-semibold text-sm flex items-center justify-center gap-2 whitespace-nowrap transition-all duration-200 focus:outline-none focus:ring-2'; const sizeStyles = { - short: 'min-w-[9.6rem] w-[9.6rem] h-[4.0rem]', - long: 'min-w-[34.8rem] w-[34.8rem] h-[4.0rem]', - fit: 'w-fit h-[4.0rem] px-[1.6rem]', - full: 'w-full h-[4.0rem]', + // Legacy sizes + short: 'min-w-[96px] px-4 py-2.5', + long: 'min-w-[348px] px-6 py-2.5', + fit: 'w-fit px-6 py-2.5', + full: 'w-full px-6 py-2.5', + // New semantic sizes + sm: 'px-4 py-2 text-xs', + md: 'px-6 py-2.5 text-sm', + lg: 'px-8 py-3 text-base', }; + // Map legacy variant names to new ones + const normalizedVariant = + variant === 'blue' ? 'primary' : variant === 'dark' ? 'secondary' : variant; + const variantStyles = { - blue: 'bg-blue text-white', - dark: 'bg-darkgray100 text-white', - light: 'bg-white text-black border border-lightgray500', - dimmed: 'bg-transparent text-lightgray500', - disabled: 'bg-lightgray300 text-lightgray500', + // Primary button (Main color) + primary: 'bg-main text-white hover:bg-main/90 focus:ring-main/20', + blue: 'bg-main text-white hover:bg-main/90 focus:ring-main/20', + + // Secondary button (Outline style) + secondary: 'bg-gray-800 text-white hover:bg-gray-700 focus:ring-gray-300', + dark: 'bg-gray-800 text-white hover:bg-gray-700 focus:ring-gray-300', + + // Light/Outline button + light: + 'bg-white text-gray-700 border border-gray-200 hover:border-gray-300 hover:bg-gray-50 focus:ring-main/20', + + // Ghost button + ghost: 'bg-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:ring-gray-200', + dimmed: 'bg-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-50 focus:ring-gray-200', + + // Danger button + danger: + 'bg-red-50 border border-red-100 shadow-red-500/10 text-red-600 hover:border-red-200 hover:bg-red-100/80', + 'danger-outline': + 'bg-white text-red-600 border border-red-200 hover:bg-red-50 hover:border-red-300 focus:ring-red-200', + + // Disabled state + disabled: 'bg-gray-200 text-gray-400 cursor-not-allowed border border-gray-200', }; return ( diff --git a/apps/admin/src/components/common/Buttons/PlusButton.tsx b/apps/admin/src/components/common/Buttons/PlusButton.tsx deleted file mode 100644 index 9217c1a0..00000000 --- a/apps/admin/src/components/common/Buttons/PlusButton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { IcPlus } from '@svg'; -import { ButtonHTMLAttributes } from 'react'; - -interface PlusButtonProps extends ButtonHTMLAttributes { - variant?: 'dark' | 'light'; -} - -const PlusButton = ({ variant = 'dark', ...props }: PlusButtonProps) => { - const variantStyles = { - dark: 'bg-darkgray100', - light: 'bg-lightgray500', - }; - - return ( - - ); -}; - -export default PlusButton; diff --git a/apps/admin/src/components/common/Buttons/PrevPageButton.tsx b/apps/admin/src/components/common/Buttons/PrevPageButton.tsx deleted file mode 100644 index 3655703d..00000000 --- a/apps/admin/src/components/common/Buttons/PrevPageButton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useNavigation } from '@hooks'; -import { IcLeftSm } from '@svg'; -import { ButtonHTMLAttributes } from 'react'; - -const PrevPageButton = ({ ...props }: ButtonHTMLAttributes) => { - const { goBack } = useNavigation(); - return ( - - ); -}; - -export default PrevPageButton; diff --git a/apps/admin/src/components/common/Buttons/index.ts b/apps/admin/src/components/common/Buttons/index.ts index bcd5b17a..729fd1d0 100644 --- a/apps/admin/src/components/common/Buttons/index.ts +++ b/apps/admin/src/components/common/Buttons/index.ts @@ -2,7 +2,5 @@ import Button from './Button'; import DeleteButton from './DeleteButton'; import FloatingButton from './FloatingButton'; import IconButton from './IconButton'; -import PlusButton from './PlusButton'; -import PrevPageButton from './PrevPageButton'; -export { Button, DeleteButton, FloatingButton, IconButton, PlusButton, PrevPageButton }; +export { Button, DeleteButton, FloatingButton, IconButton }; diff --git a/apps/admin/src/components/common/ComponentWithLabel.tsx b/apps/admin/src/components/common/ComponentWithLabel.tsx index df577fc6..c2f35c16 100644 --- a/apps/admin/src/components/common/ComponentWithLabel.tsx +++ b/apps/admin/src/components/common/ComponentWithLabel.tsx @@ -15,16 +15,17 @@ const ComponentWithLabel = ({ }: ComponentWithLabelProps) => { const directionStyle = { row: 'flex items-center', - column: 'flex-col items-start', + column: 'flex flex-col items-start', }; + return ( -
-
+
- {children} + +
{children}
); }; diff --git a/apps/admin/src/components/common/GNB.tsx b/apps/admin/src/components/common/GNB.tsx index 2911cd1c..cf754730 100644 --- a/apps/admin/src/components/common/GNB.tsx +++ b/apps/admin/src/components/common/GNB.tsx @@ -1,121 +1,292 @@ -import { HTMLAttributes } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Link } from '@tanstack/react-router'; -import { IcFolder, IcList, IcPublish, IcTag, IcTeacher } from '@svg'; +import { + FileText, + Calendar, + GraduationCap, + Search, + ChevronDown, + ChevronRight, + Package, + ChartNoAxesCombined, + Users, + Megaphone, + Tags, + MessageCircle, + Bell, +} from 'lucide-react'; +import { getStudent } from '@apis'; +import { useSelectedStudent } from '@hooks'; +import { components } from '@schema'; -interface GNBMenuProps extends HTMLAttributes { - isSelected: boolean; - children: React.ReactNode; -} +import { useSidebar } from '@/contexts/SidebarContext'; -const GNBMenu = ({ isSelected, children }: GNBMenuProps) => { - const bgStyles = isSelected ? 'bg-darkgray200' : ''; +interface NavItemProps { + to: string; + icon: React.ReactNode; + label: string; + isCollapsed: boolean; +} +const NavItem = ({ to, icon, label, isCollapsed }: NavItemProps) => { return ( -
+ + {({ isActive }) => ( +
+
+ {icon} +
+ {label} +
+ )} + + ); +}; + +interface SectionTitleProps { + children: React.ReactNode; + isCollapsed: boolean; +} + +const SectionTitle = ({ children, isCollapsed }: SectionTitleProps) => { + return isCollapsed ? ( +
+
+
+ ) : ( +
{children}
); }; const GNB = () => { + const { isCollapsed, toggleCollapse } = useSidebar(); + const [studentSearchOpen, setStudentSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const { selectedStudent, setSelectedStudent } = useSelectedStudent(); + const { data: studentListResponse } = getStudent({ query: searchQuery }); + const studentList = studentListResponse?.data ?? []; + const searchInputRef = useRef(null); + + useEffect(() => { + if (studentSearchOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [studentSearchOpen]); + + useEffect(() => { + if (isCollapsed) { + setStudentSearchOpen(false); + } + }, [isCollapsed]); + + const handleStudentSelect = (student: components['schemas']['StudentResp']) => { + setSelectedStudent(student); + setStudentSearchOpen(false); + setSearchQuery(''); + }; + return ( -
-
- 로고이미지 +
+
+ {/* Header */} +
+
+
+ + + + +
+
+
+ + {/* Navigation */} +
-
); }; diff --git a/apps/admin/src/components/common/Header.tsx b/apps/admin/src/components/common/Header.tsx index 6390f092..a7c896f3 100644 --- a/apps/admin/src/components/common/Header.tsx +++ b/apps/admin/src/components/common/Header.tsx @@ -1,41 +1,67 @@ -import { Button, DeleteButton, PrevPageButton } from '@components'; +import { useNavigation } from '@hooks'; +import { ArrowLeft, LucideIcon } from 'lucide-react'; +import { LinearBlur } from 'progressive-blur'; interface HeaderProps { title: string; - description?: string; - deleteButton?: string; - onClickDelete?: () => void; - actionButton?: string; - onClickAction?: () => void; + children: React.ReactNode; } -const Header = ({ - title, - description, - deleteButton, - onClickDelete, - actionButton, - onClickAction, -}: HeaderProps) => { +const Button = ({ + children, + Icon, + color, + onClick, +}: { + children: React.ReactNode; + Icon: LucideIcon; + color: 'main' | 'gray' | 'destructive'; + onClick: () => void; +}) => { + const colorStyle = { + main: 'bg-main shadow-main/20 text-white', + gray: 'border border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50', + destructive: + 'bg-red-50 border border-red-100 shadow-red-500/10 text-red-600 hover:border-red-200 hover:bg-red-100/80', + }; return ( -
- {/* */} -
-
-

{title}

- {description && ( -

{description}

- )} + + ); +}; + +const Header = ({ title, children }: HeaderProps) => { + const { goBack } = useNavigation(); + return ( +
+
+
+
+ +

{title}

+
+ {children}
- {deleteButton && } - {actionButton && ( - - )}
+
); }; +Header.Button = Button; + +export { Button }; export default Header; diff --git a/apps/admin/src/components/common/Inputs/AnswerInput.tsx b/apps/admin/src/components/common/Inputs/AnswerInput.tsx index aa6add9c..6bc3d077 100644 --- a/apps/admin/src/components/common/Inputs/AnswerInput.tsx +++ b/apps/admin/src/components/common/Inputs/AnswerInput.tsx @@ -1,17 +1,17 @@ -import { Input } from '@components'; +import { Input, SegmentedControl } from '@components'; import { ProblemAnswerType } from '@types'; -import { InputHTMLAttributes } from 'react'; +import { ChangeEvent, InputHTMLAttributes } from 'react'; import type { UseFormRegisterReturn } from 'react-hook-form'; const AnswerTypeList = ['MULTIPLE_CHOICE', 'SHORT_ANSWER']; const AnswerTypeName = { - MULTIPLE_CHOICE: '객', - SHORT_ANSWER: '주', + MULTIPLE_CHOICE: '객관식', + SHORT_ANSWER: '주관식', }; -interface AnswerTypeSectionProps extends Omit, 'ref'> { +interface AnswerTypeSectionProps extends Pick, 'disabled'> { selectedAnswerType: ProblemAnswerType | undefined; - registration: UseFormRegisterReturn; + onChange: (value: ProblemAnswerType) => void; } interface AnswerInputSectionProps extends Omit, 'ref'> { @@ -22,38 +22,26 @@ interface AnswerInputSectionProps extends Omit { - return
{children}
; + return
{children}
; }; -const AnswerTypeSection = ({ - selectedAnswerType, - registration, - ...props -}: AnswerTypeSectionProps) => { +const AnswerTypeSection = ({ selectedAnswerType, onChange, disabled }: AnswerTypeSectionProps) => { + const handleChange = (nextValue: string) => { + onChange(nextValue as ProblemAnswerType); + }; + return ( -
- {AnswerTypeList.map((answerType) => ( - - ))} -
+ <> + ({ + label: AnswerTypeName[answerType as ProblemAnswerType], + value: answerType, + }))} + /> + ); }; @@ -64,6 +52,18 @@ const AnswerInputSection = ({ registration, ...props }: AnswerInputSectionProps) => { + const handleMultipleChoiceChange = (nextValue: string) => { + if (!registration?.onChange) return; + const syntheticEvent = { + target: { + name: registration.name, + value: nextValue, + }, + type: 'change', + } as unknown as ChangeEvent; + registration.onChange(syntheticEvent); + }; + return ( <> {selectedAnswerType === 'SHORT_ANSWER' && ( @@ -73,35 +73,31 @@ const AnswerInputSection = ({ onBlur={registration.onBlur} ref={(el) => registration.ref(el)} {...props} - className={`${isError ? 'border-red' : ''}`} + className={`${isError ? 'border-red-300 focus:border-red-500 focus:ring-red-100' : ''}`} type='number' + placeholder='답안 입력' /> )} {selectedAnswerType === 'MULTIPLE_CHOICE' && ( -
- {Array.from({ length: 5 }, (_, i) => i + 1).map((num) => ( - - ))} +
+ { + const value = String(i + 1); + return { label: value, value }; + })} + /> + registration.ref(el)} + />
)} diff --git a/apps/admin/src/components/common/Inputs/Input.tsx b/apps/admin/src/components/common/Inputs/Input.tsx index 5a43ead4..934d281d 100644 --- a/apps/admin/src/components/common/Inputs/Input.tsx +++ b/apps/admin/src/components/common/Inputs/Input.tsx @@ -6,10 +6,12 @@ const Input = forwardRef ); } ); +Input.displayName = 'Input'; + export default Input; diff --git a/apps/admin/src/components/common/Modals/CreateNoticeModal.tsx b/apps/admin/src/components/common/Modals/CreateNoticeModal.tsx deleted file mode 100644 index e33fec4b..00000000 --- a/apps/admin/src/components/common/Modals/CreateNoticeModal.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { Button, DateRangePicker } from '@components'; -import { useForm } from 'react-hook-form'; -import { useEffect, useCallback } from 'react'; -import { postNotice } from '@apis'; -import { useInvalidate } from '@hooks'; -import { components } from '@schema'; - -interface Props { - selectedStudent: components['schemas']['StudentResp'] | null; - onClose: () => void; -} - -interface FormData { - startAt: string; - endAt: string; - content: string; -} - -const CreateNoticeModal = ({ selectedStudent, onClose }: Props) => { - const { - register, - handleSubmit, - formState: { errors }, - setValue, - watch, - } = useForm({ - mode: 'onChange', - }); - const { mutate: createNotice } = postNotice(); - const { invalidateNotice } = useInvalidate(); - - const startAt = watch('startAt'); - const endAt = watch('endAt'); - - // 날짜 필드 validation 등록 - useEffect(() => { - register('startAt', { - required: '공지 시작일을 선택해주세요.', - }); - register('endAt', { - required: '공지 종료일을 선택해주세요.', - validate: (value) => { - if (startAt && value < startAt) { - return '종료일은 시작일보다 늦어야 합니다.'; - } - return true; - }, - }); - }, [register, startAt]); - - const handleDateRangeChange = useCallback( - (startDate: string, endDate: string) => { - console.log('CreateNoticeModal: 받은 날짜 범위', { startDate, endDate }); - setValue('startAt', startDate, { shouldValidate: true }); - setValue('endAt', endDate, { shouldValidate: true }); - }, - [setValue] - ); - - const onSubmit = (data: FormData) => { - if (!selectedStudent) return; - - console.log('CreateNoticeModal: 제출할 데이터', { - startAt: data.startAt, - endAt: data.endAt, - content: data.content, - studentId: selectedStudent.id, - }); - - createNotice( - { - body: { - startAt: data.startAt, - endAt: data.endAt, - content: data.content, - studentId: selectedStudent.id, - }, - }, - { - onSuccess: () => { - invalidateNotice(); - onClose(); - }, - } - ); - }; - - if (!selectedStudent) { - return ( -
-

공지 작성

-

- 공지를 작성하려면 먼저 학생을 선택해주세요. -

-
- -
-
- ); - } - - return ( -
-

공지 작성

-
-
-
- -
- -
-