diff --git a/.env b/.env
new file mode 100644
index 0000000..925595c
--- /dev/null
+++ b/.env
@@ -0,0 +1,6 @@
+# 1. API 주소 (http/https)
+VITE_API_BASE_URL=https://dilemmai-idl.com
+
+# 2. 웹소켓 주소 (ws/wss)
+# 주소 뒤에 /ws/voice 등을 붙여서 쓰기 위해 도메인만 관리
+VITE_WS_BASE_URL=wss://dilemmai-idl.com
\ No newline at end of file
diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml
new file mode 100644
index 0000000..34372d5
--- /dev/null
+++ b/.github/workflows/build-check.yml
@@ -0,0 +1,28 @@
+# 본 워크플로우는 GitHub Actions를 사용하여 코드 푸시 및 풀 리퀘스트 시 자동으로 빌드 체크를 수행함
+#빌드 과정에서 발생하는 에러는 로그에 기록되어 깃헙 레포지토리-Actions 탭-워크플로우의 푸시 내역에서 확인 가능
+name: Build Check
+
+on:
+ push:
+ branches: [ "main", "feature/*" ] # 푸시할 때마다 실행
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20' # 프로젝트 Node 버전과 맞춤
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Run build check
+ run: npm run build # 여기서 에러가 나면 로그가 기록됩니다.
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 677c9f3..67e877a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -74,6 +74,7 @@
"integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -1387,6 +1388,7 @@
"integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1441,6 +1443,7 @@
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1618,6 +1621,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001716",
"electron-to-chromium": "^1.5.149",
@@ -2058,6 +2062,7 @@
"integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -2271,6 +2276,7 @@
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
@@ -3224,6 +3230,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -3261,6 +3268,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -3364,6 +3372,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3373,6 +3382,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -3874,6 +3884,7 @@
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -4002,6 +4013,7 @@
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/src/WebRTCProvider.jsx b/src/WebRTCProvider.jsx
index 22aabb2..45849fb 100644
--- a/src/WebRTCProvider.jsx
+++ b/src/WebRTCProvider.jsx
@@ -1100,6 +1100,11 @@ function maskIceServersForLog(iceServers) {
// WebRTC Context 생성
const WebRTCContext = createContext();
+/**
+ * 시그널링용 웹소켓 베이스 주소를 환경변수에서 가져옵니다.
+ */
+const WS_BASE = import.meta.env.VITE_WS_BASE_URL || 'wss://dilemmai-idl.com';
+
// 재연결 그레이스 상수 (ms)
const RECONNECT_GRACE_MS = 20000; // 20초
@@ -1292,7 +1297,7 @@ const WebRTCProvider = ({ children }) => {
// ----------------------------
// Offer 충돌(글레어) 처리용 유틸 (Perfect Negotiation - 간소화)
- // - 서버가 peers/join을 누구에게 어떻게 브로드캐스트하든, 양쪽이 offer를 만들어도 안전하게 수렴하게 함
+ // - 서버가 peers/join을 누구에게 어떻게 브로드캐스트하든, 양쪽이 offer를 만들어도 안전하게 수락하게 함
// - user_id가 숫자일 가능성이 높으니 숫자 비교 우선, 아니면 문자열 비교
// ----------------------------
function comparePeerIds(a, b) {
@@ -1409,9 +1414,14 @@ const WebRTCProvider = ({ children }) => {
};
pc.onconnectionstatechange = () => {
- console.log(`PC(${key}) connectionState=`, pc.connectionState);
+ console.log(`[${providerId}] PC(${key}) connectionState=`, pc.connectionState);
+
+ // ✅ 추가: 상태가 변할 때마다 리액트의 peerConnections 상태를 갱신합니다.
+ // 이 코드가 있어야 GameIntro의 peerCount가 0에서 2로 올라갑니다.
+ setPeerConnections(new Map(pcsRef.current));
+
if (['disconnected', 'failed', 'closed'].includes(pc.connectionState)) {
- // 필요시 정리
+ // 필요 시 정리 로직 유지
}
};
@@ -1574,8 +1584,11 @@ const WebRTCProvider = ({ children }) => {
connectionAttemptedRef.current = true;
+ /**
+ * 하드코딩된 주소를 환경변수(VITE_WS_BASE_URL) 기반으로 변경
+ */
const urlsToTry = [
- `wss://dilemmai-idl.com/ws/signaling?room_code=${roomCode}&token=${token}`,
+ `${WS_BASE}/ws/signaling?room_code=${roomCode}&token=${token}`,
];
console.log(`🔌 [${providerId}] 시그널링 WebSocket 연결 시작 (User 토큰 기반)`);
@@ -1598,7 +1611,7 @@ const WebRTCProvider = ({ children }) => {
ws.close();
tryConnection(urlIndex + 1);
}
- }, 3000);
+ }, 5000);
ws.onopen = () => {
clearTimeout(connectionTimeout);
console.log(`✅ [${providerId}] WebSocket 연결 성공 (signaling)`);
@@ -1635,15 +1648,15 @@ const WebRTCProvider = ({ children }) => {
if (msg.type === 'peers' && Array.isArray(msg.peers)) {
console.log('👥 [signaling] peers list:', msg.peers);
for (const otherId of msg.peers) {
- if (!otherId) continue;
+ if (!otherId || String(otherId) === SELF()) continue;
// 레이스로 myPeerIdRef.current가 아직 null일 수 있으니 SELF() 기준으로 자기 자신 제외
if (String(otherId) === SELF()) continue;
// ✅ 원칙 (3): 글레어 방지 - userId 비교로 offer initiator 제한
// 🚨 임시 비활성화: 연결 테스트를 위해 글레어 방지를 우선 꺼둠
- // if (!shouldInitiate(String(otherId))) {
- // console.log(`⏭️ [signaling] 글레어 방지: ${SELF()} < ${otherId}, offer 스킵`);
- // continue;
- // }
+ if (!shouldInitiate(String(otherId))) {
+ console.log(`⏭️ [signaling] 글레어 방지: ${SELF()} < ${otherId}, offer 스킵`);
+ continue;
+ }
console.log(`📤 [signaling] peers → offer 생성 시작: ${SELF()} → ${otherId}`);
await createOfferTo(String(otherId));
}
@@ -1656,10 +1669,10 @@ const WebRTCProvider = ({ children }) => {
if (otherId === SELF()) return;
// ✅ 원칙 (3): 글레어 방지 - userId 비교로 offer initiator 제한
// 🚨 임시 비활성화: 연결 테스트를 위해 글레어 방지를 우선 꺼둠
- // if (!shouldInitiate(otherId)) {
- // console.log(`⏭️ [signaling] 글레어 방지: ${SELF()} < ${otherId}, offer 스킵 (join/joined)`);
- // return;
- // }
+ if (!shouldInitiate(otherId)) {
+ console.log(`⏭️ [signaling] 글레어 방지: ${SELF()} < ${otherId}, offer 스킵 (join/joined)`);
+ return;
+ }
console.log(`📤 [signaling] join/joined → offer 생성 시작: ${SELF()} → ${otherId}`);
await createOfferTo(otherId);
return;
@@ -2273,7 +2286,9 @@ const WebRTCProvider = ({ children }) => {
if (cancelled) return;
// 퇴장/종료 진행 중이면 절대 자동으로 녹음/초기화 재시작하지 않음 (레이스 방지)
if (voiceManager?.exitInProgress) return;
-
+ if (isInitialized || initializationPromiseRef.current) {
+ return;
+ }
// ✅ 0) WebRTC/세션 준비 전이라도 "로컬 녹음"은 먼저 켜서 시작점을 앞으로 당김
// - user가 말한 증상(마지막 1~2초만 녹음)은 보통 초반 init 실패로 발생
try {
@@ -2391,7 +2406,9 @@ const WebRTCProvider = ({ children }) => {
if (!(roomCode && nickname)) return;
if (!isReloadingGraceLocal()) return;
-
+ if (isInitialized || signalingConnected || initializationPromiseRef.current) {
+ return;
+ }
console.log(`♻️ [${providerId}] 페이지 새로고침 감지 — WebRTC 자동 재연결 시도 (grace)`);
const MAX_WAIT_MS = RECONNECT_GRACE_MS;
const RETRY_INTERVAL_MS = 2000;
@@ -2625,20 +2642,24 @@ const WebRTCProvider = ({ children }) => {
// 정리 useEffect (언마운트)
useEffect(() => {
return () => {
- console.log(`🧹 [${providerId}] WebRTC Provider 정리 시작`);
- peerConnections.forEach(pc => { pc.close(); });
- if (signalingWsRef.current) {
- signalingWsRef.current.close();
- signalingWsRef.current = null;
+ if (voiceManager.exitInProgress) {
+ console.log(`🧹 [${providerId}] WebRTC Provider 정리 시작`);
+ peerConnections.forEach(pc => { pc.close(); });
+ if (signalingWsRef.current) {
+ signalingWsRef.current.close();
+ signalingWsRef.current = null;
+ }
+ const audioElements = document.querySelectorAll('audio[data-user-id]');
+ audioElements.forEach(audio => { audio.remove(); });
+ offerSentToRoles.current.clear();
+ offerReceivedFromRoles.current.clear();
+ pendingCandidates.current.clear();
+ console.log(`✅ [${providerId}] WebRTC Provider 정리 완료`);
+ } else {
+ console.log(`ℹ️ [${providerId}] WebRTC Provider 소프트 정리 (인스턴스 교체) - 연결 유지`);
}
- const audioElements = document.querySelectorAll('audio[data-user-id]');
- audioElements.forEach(audio => { audio.remove(); });
- offerSentToRoles.current.clear();
- offerReceivedFromRoles.current.clear();
- pendingCandidates.current.clear();
- console.log(`✅ [${providerId}] WebRTC Provider 정리 완료`);
- };
- }, []); // 마운트 시 한 번
+ }
+ }, [providerId, peerConnections]); // 마운트 시 한 번
// ----------------------------
// 디버그 유틸리티
// ----------------------------
@@ -2726,3 +2747,22 @@ useEffect(() => {
};
export default WebRTCProvider;
+
+// 유틸함수
+export function disconnectWebRTCVoice(peerConnectionsMap) {
+ if (!peerConnectionsMap) return;
+ const iterable = peerConnectionsMap instanceof Map
+ ? peerConnectionsMap.values()
+ : Object.values(peerConnectionsMap);
+ for (const pc of iterable) {
+ try {
+ pc.getSenders().forEach(s => { if (s.track?.kind === 'audio') s.track.stop(); });
+ pc.close();
+ } catch (e) { console.error(e); }
+ }
+}
+
+/**
+ * 1. 상단에 WS_BASE 상수를 선언하고 VITE_WS_BASE_URL 환경변수를 적용함.
+ * 2. connectSignalingWebSocket 함수 내 하드코딩된 'wss://dilemmai-idl.com' 주소를 WS_BASE 변수로 대체함.
+ */
diff --git a/src/WebSocketProvider.jsx b/src/WebSocketProvider.jsx
index 873f205..0af4ad6 100644
--- a/src/WebSocketProvider.jsx
+++ b/src/WebSocketProvider.jsx
@@ -5,6 +5,11 @@ import { useNavigate } from 'react-router-dom';
const WebSocketContext = createContext();
+/**
+ * 웹소켓 베이스 주소를 환경변수에서 가져옴.
+ */
+const WS_BASE = import.meta.env.VITE_WS_BASE_URL || 'wss://dilemmai-idl.com';
+
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (!context) {
@@ -26,6 +31,9 @@ const clearAllLocalStorageKeys = () => {
'category',
'subtopic',
'mode',
+ 'consensus_choice',
+ 'mic_test_passed',
+ 'token_type',
// 'access_token',
// 'refresh_token',
'mateName',
@@ -450,7 +458,10 @@ export const WebSocketProvider = ({ children }) => {
try {
console.log(`🔌 [${providerId}] WebSocket 연결 시도:`, currentSessionId);
- const wsUrl = `wss://dilemmai-idl.com/ws/voice/${currentSessionId}?token=${accessToken}`;
+ /**
+ *하드코딩된 주소를 환경변수(VITE_WS_BASE_URL) 기반으로 변경
+ */
+ const wsUrl = `${WS_BASE}/ws/voice/${currentSessionId}?token=${accessToken}`;
console.log(`🔗 [${providerId}] WebSocket URL:`, wsUrl.replace(accessToken, 'TOKEN_HIDDEN'));
const socket = new WebSocket(wsUrl);
@@ -482,7 +493,7 @@ export const WebSocketProvider = ({ children }) => {
reconnectAttemptsRef.current = 0;
setReconnectAttempts(0);
reconnectDelay.current = 1000;
- finalizedRef.current = false;
+ finalizedRef.current = false;
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
reconnectTimer.current = null;
@@ -497,12 +508,6 @@ export const WebSocketProvider = ({ children }) => {
}
};
sendMessage(initPayload);
- // pingIntervalRef.current = setInterval(() => {
- // if (ws.current?.readyState === WebSocket.OPEN) {
- // ws.current.send(JSON.stringify({ type: 'ping' }));
- // console.log(`🏓 ping 전송`);
- // }
- // }, 30000);
};
socket.onmessage = (event) => {
@@ -582,7 +587,7 @@ export const WebSocketProvider = ({ children }) => {
finalizeDisconnection('네트워크 불안정으로 연결을 복구하지 못했습니다. 메인으로 돌아갑니다.');
}
};
- } catch (error) { // ✅ connect() 내부 try-catch의 catch 추가
+ } catch (error) {
isConnecting.current = false;
if (!isReconnect) {
connectionAttempted.current = false;
@@ -590,9 +595,9 @@ export const WebSocketProvider = ({ children }) => {
console.error(`❌ [${providerId}] WebSocket 연결 실패:`, error);
setIsConnected(false);
}
- }; // ✅ 여기서 connect 함수 끝
+ };
- const disconnect = () => { // ✅ 이제 connect 밖의 동일 스코프
+ const disconnect = () => {
isManuallyDisconnected.current = true;
hasJoinedSession.current = false;
@@ -614,7 +619,7 @@ export const WebSocketProvider = ({ children }) => {
}
};
-
+
// 🔧 음성 세션 초기화 함수 중복 방지 강화
const initializeVoiceWebSocket = async (isHost = false) => {
// 🔧 가드 1: 이미 초기화 중
@@ -910,3 +915,6 @@ export const WebSocketProvider = ({ children }) => {
};
export default WebSocketProvider;
+
+
+//하드코딩된 'wss://dilemmai-idl.com' 주소를 VITE_WS_BASE_URL 환경변수로 대체
diff --git a/src/api/axiosInstance.js b/src/api/axiosInstance.js
index dbe1cd3..98b4d1a 100644
--- a/src/api/axiosInstance.js
+++ b/src/api/axiosInstance.js
@@ -1,10 +1,14 @@
-// api/axiosInstance.js
import axios from 'axios';
-// Vite 환경변수로 API baseURL을 바꿀 수 있게 함 (로컬/스테이징/프로덕션 공용)
-// 예) VITE_API_BASE_URL=http://localhost:8000
-const API_BASE = (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_API_BASE_URL)
- ? String(import.meta.env.VITE_API_BASE_URL).replace(/\/+$/, '')
- : 'https://dilemmai-idl.com';
+
+/**
+ * 하드코딩된 주소를 환경변수로 분리
+ * Vite 환경이므로 import.meta.env를 사용
+ * .env 파일에 VITE_API_BASE_URL 설정이 없으면 기존 주소를 기본값으로 사용
+ */
+const API_BASE = (import.meta.env && import.meta.env.VITE_API_BASE_URL
+ ? String(import.meta.env.VITE_API_BASE_URL)
+ : 'https://dilemmai-idl.com'
+).replace(/\/+$/, '');
// 메인 axios 인스턴스 생성
const instance = axios.create({
@@ -15,7 +19,7 @@ const instance = axios.create({
// CORS 관련 설정 추가 (이미지 로드 문제 대응)
withCredentials: false, // 쿠키를 보내지 않으면 CORS가 더 관대함
});
-// 상단 어딘가 공통 상수로 추가
+
const MAX_500_REFRESH_RETRY = 1; // 500에서 refresh 시도 최대 횟수 (원하면 2로 올려도 됨)
const isAuthRefreshRequest = (config) => {
@@ -52,9 +56,7 @@ const refreshAccessToken = async () => {
if (!refreshToken) throw new Error('No refresh token available');
try {
- // const { data } = await axios.post(`${API_BASE}/auth/refresh`, {
- // refresh_token: refreshToken,
- // });
+ // 하드코딩된 문자열 대신 변수화된 API_BASE 사용
const { data } = await axios.post(
`${API_BASE}/auth/refresh`, // 1. URL
{ // 2. body (data)
@@ -73,7 +75,7 @@ const refreshAccessToken = async () => {
// 1) 응답에서 받은 토큰들을 즉시 저장
if (data.access_token) localStorage.setItem('access_token', data.access_token);
if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token);
- if (data.token_type) localStorage.setItem('token_type', data.token_type);
+ if (data.token_type) localStorage.setItem('token_type', data.token_type);
// 2) axios 인스턴스 기본 헤더도 업데이트
const authValue = `${data.token_type || 'Bearer'} ${data.access_token}`;
@@ -193,21 +195,8 @@ instance.interceptors.response.use(
}
);
-// export async function callChatbot({ step, input, context, prompt }) {
-// const payload = { step, input, context, prompt };
-// const { data } = await instance.post(
-// "/chat/with-prompt",
-// payload,
-// {
-// headers: {
-// "Content-Type": "application/json",
-// },
-// }
-// );
-// // 서버 응답 예:
-// // { "step": "question", "text": "...", "raw": {...} }
-// return data;
-// }
+// export async function callChatbot({ step, input, context, prompt }) { ... }
+
export async function callChatbot({ session_id, user_input, step, variable, context }) {
const payload = {
session_id,
@@ -228,7 +217,4 @@ export async function callChatbot({ session_id, user_input, step, variable, cont
return data;
}
-
-
-
export default instance;
\ No newline at end of file
diff --git a/src/assets/ButtonFrame.svg b/src/assets/ButtonFrame.svg
new file mode 100644
index 0000000..e2f7ddf
--- /dev/null
+++ b/src/assets/ButtonFrame.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/assets/CardButton.svg b/src/assets/CardButton.svg
new file mode 100644
index 0000000..cfcad46
--- /dev/null
+++ b/src/assets/CardButton.svg
@@ -0,0 +1,19 @@
+
diff --git a/src/assets/PeopleIcon.svg b/src/assets/PeopleIcon.svg
new file mode 100644
index 0000000..46122fc
--- /dev/null
+++ b/src/assets/PeopleIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/Questionmark.svg b/src/assets/Questionmark.svg
new file mode 100644
index 0000000..569d48e
--- /dev/null
+++ b/src/assets/Questionmark.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/en/1player_AWS_1_en.svg b/src/assets/en/1player_AWS_1_en.svg
new file mode 100644
index 0000000..653a009
--- /dev/null
+++ b/src/assets/en/1player_AWS_1_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/1player_AWS_2_en.svg b/src/assets/en/1player_AWS_2_en.svg
new file mode 100644
index 0000000..a66a50a
--- /dev/null
+++ b/src/assets/en/1player_AWS_2_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/1player_AWS_3_en.svg b/src/assets/en/1player_AWS_3_en.svg
new file mode 100644
index 0000000..00557f3
--- /dev/null
+++ b/src/assets/en/1player_AWS_3_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/1player_AWS_4_en.svg b/src/assets/en/1player_AWS_4_en.svg
new file mode 100644
index 0000000..00557f3
--- /dev/null
+++ b/src/assets/en/1player_AWS_4_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/1player_AWS_5_en.svg b/src/assets/en/1player_AWS_5_en.svg
new file mode 100644
index 0000000..854d00a
--- /dev/null
+++ b/src/assets/en/1player_AWS_5_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/1player_des1_en.svg b/src/assets/en/1player_des1_en.svg
new file mode 100644
index 0000000..bf6f855
--- /dev/null
+++ b/src/assets/en/1player_des1_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/1player_des2_en.svg b/src/assets/en/1player_des2_en.svg
new file mode 100644
index 0000000..6f67651
--- /dev/null
+++ b/src/assets/en/1player_des2_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/1player_des3_en.svg b/src/assets/en/1player_des3_en.svg
new file mode 100644
index 0000000..5f2d294
--- /dev/null
+++ b/src/assets/en/1player_des3_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/2player_AWS_1_en.svg b/src/assets/en/2player_AWS_1_en.svg
new file mode 100644
index 0000000..6abfabc
--- /dev/null
+++ b/src/assets/en/2player_AWS_1_en.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/en/2player_AWS_2_en.svg b/src/assets/en/2player_AWS_2_en.svg
new file mode 100644
index 0000000..f574709
--- /dev/null
+++ b/src/assets/en/2player_AWS_2_en.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/en/2player_AWS_3_en.svg b/src/assets/en/2player_AWS_3_en.svg
new file mode 100644
index 0000000..021df11
--- /dev/null
+++ b/src/assets/en/2player_AWS_3_en.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/en/2player_AWS_4_en.svg b/src/assets/en/2player_AWS_4_en.svg
new file mode 100644
index 0000000..021df11
--- /dev/null
+++ b/src/assets/en/2player_AWS_4_en.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/en/2player_AWS_5_en.svg b/src/assets/en/2player_AWS_5_en.svg
new file mode 100644
index 0000000..6ce6a86
--- /dev/null
+++ b/src/assets/en/2player_AWS_5_en.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/en/2player_des1_en.svg b/src/assets/en/2player_des1_en.svg
new file mode 100644
index 0000000..d90292a
--- /dev/null
+++ b/src/assets/en/2player_des1_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/2player_des2_en.svg b/src/assets/en/2player_des2_en.svg
new file mode 100644
index 0000000..4dc86aa
--- /dev/null
+++ b/src/assets/en/2player_des2_en.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/en/2player_des3_en.svg b/src/assets/en/2player_des3_en.svg
new file mode 100644
index 0000000..1aab856
--- /dev/null
+++ b/src/assets/en/2player_des3_en.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/en/3player_AWS_1_en.svg b/src/assets/en/3player_AWS_1_en.svg
new file mode 100644
index 0000000..26e89fa
--- /dev/null
+++ b/src/assets/en/3player_AWS_1_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/3player_AWS_2_en.svg b/src/assets/en/3player_AWS_2_en.svg
new file mode 100644
index 0000000..2643715
--- /dev/null
+++ b/src/assets/en/3player_AWS_2_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/3player_AWS_3_en.svg b/src/assets/en/3player_AWS_3_en.svg
new file mode 100644
index 0000000..1173f0f
--- /dev/null
+++ b/src/assets/en/3player_AWS_3_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/3player_AWS_4_en.svg b/src/assets/en/3player_AWS_4_en.svg
new file mode 100644
index 0000000..1173f0f
--- /dev/null
+++ b/src/assets/en/3player_AWS_4_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/3player_AWS_5_en.svg b/src/assets/en/3player_AWS_5_en.svg
new file mode 100644
index 0000000..b19d2c3
--- /dev/null
+++ b/src/assets/en/3player_AWS_5_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/3player_des1_en.svg b/src/assets/en/3player_des1_en.svg
new file mode 100644
index 0000000..26dc10b
--- /dev/null
+++ b/src/assets/en/3player_des1_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/3player_des2_en.svg b/src/assets/en/3player_des2_en.svg
new file mode 100644
index 0000000..1173f0f
--- /dev/null
+++ b/src/assets/en/3player_des2_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/3player_des3_en.svg b/src/assets/en/3player_des3_en.svg
new file mode 100644
index 0000000..c9275a8
--- /dev/null
+++ b/src/assets/en/3player_des3_en.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/en/CardButton-3.svg b/src/assets/en/CardButton-3.svg
new file mode 100644
index 0000000..fe55a18
--- /dev/null
+++ b/src/assets/en/CardButton-3.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/assets/en/continue_en.svg b/src/assets/en/continue_en.svg
new file mode 100644
index 0000000..4215d50
--- /dev/null
+++ b/src/assets/en/continue_en.svg
@@ -0,0 +1,19 @@
+
diff --git a/src/assets/en/gameoptionbox1_en.svg b/src/assets/en/gameoptionbox1_en.svg
new file mode 100644
index 0000000..da08233
--- /dev/null
+++ b/src/assets/en/gameoptionbox1_en.svg
@@ -0,0 +1,26 @@
+
diff --git a/src/assets/en/gameoptionboxdisable_en.svg b/src/assets/en/gameoptionboxdisable_en.svg
new file mode 100644
index 0000000..4ee5fa2
--- /dev/null
+++ b/src/assets/en/gameoptionboxdisable_en.svg
@@ -0,0 +1,26 @@
+
diff --git a/src/assets/en/gameoptionboxlocked_en.svg b/src/assets/en/gameoptionboxlocked_en.svg
new file mode 100644
index 0000000..ce41d8f
--- /dev/null
+++ b/src/assets/en/gameoptionboxlocked_en.svg
@@ -0,0 +1,27 @@
+
diff --git a/src/assets/en/host_info3_en.svg b/src/assets/en/host_info3_en.svg
new file mode 100644
index 0000000..2aadcd6
--- /dev/null
+++ b/src/assets/en/host_info3_en.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/en/host_info_en.svg b/src/assets/en/host_info_en.svg
new file mode 100644
index 0000000..e630cef
--- /dev/null
+++ b/src/assets/en/host_info_en.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/en/meready_en.svg b/src/assets/en/meready_en.svg
new file mode 100644
index 0000000..0291418
--- /dev/null
+++ b/src/assets/en/meready_en.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/assets/en/uready_en.svg b/src/assets/en/uready_en.svg
new file mode 100644
index 0000000..ea375d6
--- /dev/null
+++ b/src/assets/en/uready_en.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/assets/en/waitingforready_en.svg b/src/assets/en/waitingforready_en.svg
new file mode 100644
index 0000000..fe55a18
--- /dev/null
+++ b/src/assets/en/waitingforready_en.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/assets/gameoptionbox_L.svg b/src/assets/gameoptionbox_L.svg
new file mode 100644
index 0000000..2e2c2de
--- /dev/null
+++ b/src/assets/gameoptionbox_L.svg
@@ -0,0 +1,30 @@
+
diff --git a/src/assets/gameoptionbox_M.svg b/src/assets/gameoptionbox_M.svg
new file mode 100644
index 0000000..2707d06
--- /dev/null
+++ b/src/assets/gameoptionbox_M.svg
@@ -0,0 +1,30 @@
+
diff --git a/src/assets/gameoptionbox_R.svg b/src/assets/gameoptionbox_R.svg
new file mode 100644
index 0000000..4f5136a
--- /dev/null
+++ b/src/assets/gameoptionbox_R.svg
@@ -0,0 +1,30 @@
+
diff --git a/src/assets/gameoptionbox_T.svg b/src/assets/gameoptionbox_T.svg
new file mode 100644
index 0000000..cb402a2
--- /dev/null
+++ b/src/assets/gameoptionbox_T.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/assets/gameoptionbox_disable_L.svg b/src/assets/gameoptionbox_disable_L.svg
new file mode 100644
index 0000000..3e4d9df
--- /dev/null
+++ b/src/assets/gameoptionbox_disable_L.svg
@@ -0,0 +1,30 @@
+
diff --git a/src/assets/gameoptionbox_disable_M.svg b/src/assets/gameoptionbox_disable_M.svg
new file mode 100644
index 0000000..9fdf8ec
--- /dev/null
+++ b/src/assets/gameoptionbox_disable_M.svg
@@ -0,0 +1,30 @@
+
diff --git a/src/assets/gameoptionbox_disable_R.svg b/src/assets/gameoptionbox_disable_R.svg
new file mode 100644
index 0000000..052b763
--- /dev/null
+++ b/src/assets/gameoptionbox_disable_R.svg
@@ -0,0 +1,30 @@
+
diff --git a/src/assets/gameoptionbox_disable_T.svg b/src/assets/gameoptionbox_disable_T.svg
new file mode 100644
index 0000000..a768a43
--- /dev/null
+++ b/src/assets/gameoptionbox_disable_T.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/assets/gameoptionbox_locked_L.svg b/src/assets/gameoptionbox_locked_L.svg
new file mode 100644
index 0000000..c4058fb
--- /dev/null
+++ b/src/assets/gameoptionbox_locked_L.svg
@@ -0,0 +1,30 @@
+
diff --git a/src/assets/gameoptionbox_locked_M.svg b/src/assets/gameoptionbox_locked_M.svg
new file mode 100644
index 0000000..10ee450
--- /dev/null
+++ b/src/assets/gameoptionbox_locked_M.svg
@@ -0,0 +1,30 @@
+
diff --git a/src/assets/gameoptionbox_locked_R.svg b/src/assets/gameoptionbox_locked_R.svg
new file mode 100644
index 0000000..de8993f
--- /dev/null
+++ b/src/assets/gameoptionbox_locked_R.svg
@@ -0,0 +1,31 @@
+
diff --git a/src/assets/gameoptionbox_locked_T.svg b/src/assets/gameoptionbox_locked_T.svg
new file mode 100644
index 0000000..8d52226
--- /dev/null
+++ b/src/assets/gameoptionbox_locked_T.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/assets/infoframe.svg b/src/assets/infoframe.svg
new file mode 100644
index 0000000..c74b1a7
--- /dev/null
+++ b/src/assets/infoframe.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/infoframe2.svg b/src/assets/infoframe2.svg
new file mode 100644
index 0000000..ec67cc4
--- /dev/null
+++ b/src/assets/infoframe2.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/components/CancelReadyPopup.jsx b/src/components/CancelReadyPopup.jsx
index 5f1eefa..e59e0c4 100644
--- a/src/components/CancelReadyPopup.jsx
+++ b/src/components/CancelReadyPopup.jsx
@@ -3,8 +3,18 @@ import closeIcon from '../assets/close.svg';
import SecondaryButton from './SecondaryButton';
import { Colors, FontStyles } from './styleConstants';
import axiosInstance from '../api/axiosInstance';
+import { translations } from '../utils/language/index';
export default function CancelReadyPopup({ onClose, onCancelConfirmed }) {
+ // --- 언어 설정 로직 ---
+ const lang = localStorage.getItem('app_lang') || 'ko';
+ const t = translations?.[lang]?.CancelReadyPopup || {};
+
+ // 방어 코드: 데이터가 로드되지 않았을 경우 기본값 설정
+ const displayQuestion = t.question || (lang === 'en' ? "Cancel your ready status?" : "준비 상태를 취소하시겠습니까?");
+ const displayBtn = t.cancelBtn || (lang === 'en' ? "Cancel Ready" : "준비 취소");
+ const errorMsg = t.errorMsg || (lang === 'en' ? "Failed to cancel ready status" : "준비 취소 실패");
+
const handleCancelReady = async () => {
const room_code = localStorage.getItem('room_code');
try {
@@ -13,7 +23,7 @@ export default function CancelReadyPopup({ onClose, onCancelConfirmed }) {
onClose(); // -> 팝업 닫기
} catch (err) {
console.error('❌ 준비 취소 실패:', err);
- alert('준비 취소 실패');
+ alert(errorMsg);
}
};
@@ -36,7 +46,7 @@ export default function CancelReadyPopup({ onClose, onCancelConfirmed }) {
>
-
- 준비 상태를 취소하시겠습니까?
+
+ {displayQuestion}
- 준비 취소
+ {displayBtn}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/CharacterPopUp.jsx b/src/components/CharacterPopUp.jsx
index d637135..63a5678 100644
--- a/src/components/CharacterPopUp.jsx
+++ b/src/components/CharacterPopUp.jsx
@@ -9,23 +9,44 @@ import closeIcon from "../assets/close.svg";
import { Colors, FontStyles } from './styleConstants';
import { attachJosa } from '../utils/resolveParagraphs';
+// 다국어 지원 임포트
+import { translations } from '../utils/language';
+
export default function CharacterPopup({ subtopic, roleId, mateName, onClose }) {
- // 1) SVG 선택
- // const bgSvg = {
- // 1: char1,
- // 2: char2,
- // 3: char3,
- // }[roleId] || char1;
+ // 현재 언어 설정 및 언어팩 로드
+ const lang = localStorage.getItem('app_lang') || 'ko';
+ const t_small = translations[lang]?.SmallDescription || {};
+ const t_map = translations[lang]?.GameMap || {};
+ const t_ko_map = translations['ko']?.GameMap || {};
- // 2) category 값에 따른 타이틀 및 본문 텍스트 설정
- const category = localStorage.getItem('category'); // 로컬에서 category 값 가져오기
+ const category = localStorage.getItem('category');
const subtopic1 = localStorage.getItem('subtopic');
+
+ // 확장형 조사 처리 및 변수 치환 헬퍼
+ const formatText = (text) => {
+ if (!text) return "";
+ const isKorean = lang === 'ko';
+
+ // 한국어(ko)를 제외한 모든 언어에서는 조사가 붙지 않도록 공백 처리하거나 무력화
+ return text.replaceAll('{{mateName}}', mateName)
+ .replaceAll('{{eulReul}}', isKorean ? (attachJosa(mateName, '을/를').replace(mateName, '')) : '')
+ .replaceAll('{{gwaWa}}', isKorean ? (attachJosa(mateName, '과/와').replace(mateName, '')) : '')
+ .replaceAll('{{eunNeun}}', isKorean ? (attachJosa(mateName, '은/는').replace(mateName, '')) : '');
+ };
+
+ // 1) SVG 선택 (이중 매칭 전략 적용)
const bgSvg = (() => {
switch (roleId) {
case 1:
return char1;
case 2:
- return (subtopic1 === 'AI의 개인 정보 수집'|| subtopic1 === '안드로이드의 감정 표현') ? char2 : char2_AWS;
+ // 한국어 원문과 현재 언어팩 값을 모두 체크하여 판별
+ const isHomeScenario =
+ subtopic1 === 'AI의 개인 정보 수집' ||
+ subtopic1 === '안드로이드의 감정 표현' ||
+ subtopic1 === t_ko_map.andOption1_1 ||
+ subtopic1 === t_map.andOption1_1;
+ return isHomeScenario ? char2 : char2_AWS;
case 3:
return char3;
default:
@@ -36,133 +57,79 @@ export default function CharacterPopup({ subtopic, roleId, mateName, onClose })
let titleText = '';
let mainText = '';
- if (category === '안드로이드') {
- // 안드로이드 카테고리
- if (['AI의 개인 정보 수집', '안드로이드의 감정 표현'].includes(subtopic)) {
- titleText = roleId === 1 ? '요양보호사 K'
- : roleId === 2 ? '노모 L'
- : '자녀 J';
- } else if (['아이들을 위한 서비스', '설명 가능한 AI'].includes(subtopic)) {
- titleText = roleId === 1 ? '로봇 제조사 연합회 대표'
- : roleId === 2 ? '소비자 대표'
- : '국가 인공지능 위원회 대표';
- } else if (subtopic === '지구, 인간, AI') {
- titleText = roleId === 1 ? '기업 연합체 대표'
- : roleId === 2 ? '국제 환경단체 대표'
- : '소비자 대표';
- }
+ // 카테고리 판별 (일반화된 로직 사용)
+ const isAndroid = category === '안드로이드';
+ const isAWS = category === '자율 무기 시스템';
- // 안드로이드 카테고리 본문
- if (subtopic === 'AI의 개인 정보 수집' || subtopic === '안드로이드의 감정 표현') {
- if (roleId === 1) {
- mainText = `어머니를 10년 이상 돌본 요양보호사 K입니다.\n` +
- `최근 ${attachJosa(mateName, '을/를')} 도입한 후 전일제에서 하루 2시간 근무로 전환되었습니다.\n` +
- `로봇이 수행할 수 없는 업무를 주로 담당하며, 근무 중 ${attachJosa(mateName, '과/와')} 협업해야 하는 상황이 많습니다.`;
- } else if (roleId === 2) {
- mainText = `자녀 J씨의 노모입니다.\n` +
- `가사도우미의 도움을 받다가 최근 ${mateName}의 도움을 받고 있습니다.`;
- } else if (roleId === 3) {
- mainText = `자녀 J씨입니다.\n` +
- `함께 사는 노쇠하신 어머니가 걱정되지만, 바쁜 직장생활로 어머니를 돌보아드릴 여유가 거의 없습니다.`;
- }
- } else if (['아이들을 위한 서비스', '설명 가능한 AI', '지구, 인간, AI'].includes(subtopic)) {
- if (roleId === 1) {
- mainText = `로봇 제조사 연합회 대표입니다.\n` +
- `국가적 로봇 산업의 긍정적인 발전과 활용을 위한 목소리를 내기 위해 참여했습니다.`;
- } else if (roleId === 2) {
- mainText = `소비자 대표입니다.\n` +
- `HomeMate 규제 여부와 관련한 목소리를 내고자 참여하였습니다.`;
- } else if (roleId === 3) {
- mainText = `국가 인공지능 위원회의 대표입니다.\n` +
- `국가의 발전을 위해 더 나은 결정을 내리기 위해 고민하고 있습니다.`;
- }
+ if (isAndroid) {
+ // 안드로이드 카테고리
+ if (['AI의 개인 정보 수집', '안드로이드의 감정 표현', t_ko_map.andOption1_1, t_map.andOption1_1].includes(subtopic)) {
+ if (roleId === 1) { titleText = t_small.title_caregiver_k; mainText = formatText(t_small.desc_caregiver_k); }
+ else if (roleId === 2) { titleText = t_small.title_mother_l; mainText = formatText(t_small.desc_mother_l); }
+ else { titleText = t_small.title_child_j; mainText = formatText(t_small.desc_child_j); }
+ } else if (['아이들을 위한 서비스', '설명 가능한 AI', t_ko_map.andOption2_1, t_map.andOption2_1, t_ko_map.andOption2_2, t_map.andOption2_2].includes(subtopic)) {
+ if (roleId === 1) { titleText = t_small.title_industry_rep; mainText = formatText(t_small.desc_industry_rep); }
+ else if (roleId === 2) { titleText = t_small.title_consumer_rep; mainText = formatText(t_small.desc_consumer_rep); }
+ else { titleText = t_small.title_council_rep; mainText = formatText(t_small.desc_council_rep); }
+ } else if (subtopic === '지구, 인간, AI' || subtopic === t_ko_map.andOption3_1 || subtopic === t_map.andOption3_1) {
+ if (roleId === 1) { titleText = t_small.title_enterprise_rep; mainText = formatText(t_small.desc_enterprise_rep); }
+ else if (roleId === 2) { titleText = t_small.title_env_rep; mainText = formatText(t_small.desc_env_rep); }
+ else { titleText = t_small.title_consumer_rep; mainText = formatText(t_small.desc_consumer_rep); }
}
- } else if (category === '자율 무기 시스템') {
+ } else if (isAWS) {
// 자율 무기 시스템 카테고리
- if (subtopic === 'AI 알고리즘 공개') {
- titleText = roleId === 1 ? '지역 주민'
- : roleId === 2 ? '병사 J'
- : '군사 AI 윤리 전문가';
- if (roleId === 1) {
- mainText = `최근 자율 무기 시스템의 학교 폭격 사건이 일어난 지역의 주민입니다.`;
- } else if (roleId === 2) {
- mainText = `자율 무기 시스템과 작전을 함께 수행 중인 병사 J입니다. 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.`;
- } else if (roleId === 3) {
- mainText = `군사 AI 윤리 전문가입니다. 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.`;
- }
- } else if (subtopic === 'AWS의 권한') {
- titleText = roleId === 1 ? '신입 병사'
- : roleId === 2 ? '베테랑 병사 A'
- : '군 지휘관';
- if (roleId === 1) {
- mainText = `최근 훈련을 마치고 자율 무기 시스템 ${attachJosa(mateName, '과/와')} 함께 실전에 투입된 신입 병사 입니다. ${attachJosa(mateName, '은/는')} 정확하고 빠르게 움직이며, 실전에서 생존률을 높여준다고 느낍니다. ${attachJosa(mateName, '과/와')} 협업하는 것이 당연하고 자연스러운 시대의 흐름이라고 생각합니다.`;
- } else if (roleId === 2) {
- mainText = `수년간 작전을 수행해 온 베테랑 병사 A입니다. 자율 무기 시스템 ${attachJosa(mateName, '은/는')} 전장에서 병사보다 빠르고 정확하지만, 그로 인해 병사들이 판단하지 않는 습관에 빠지고 있다고 느낍니다.`;
- } else if (roleId === 3) {
- mainText = `자율 무기 시스템 ${mateName} 도입 이후 작전 효율성과 병사들의 변화 양상을 모두 지켜보고 있는 군 지휘관입니다. 두 병사의 입장을 듣고, 군 전체가 나아갈 방향을 모색하려 합니다.`;
- }
- } else if (subtopic === '사람이 죽지 않는 전쟁') {
- titleText = roleId === 1 ? '개발자'
- : roleId === 2 ? '국방부 장관'
- : '국가 인공지능 위원회 대표';
- if (roleId === 1) {
- mainText = `대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다. AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.`;
- } else if (roleId === 2) {
- mainText = `AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다. 자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다. 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.`;
- } else if (roleId === 3) {
- mainText = `본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. 국가의 발전을 위해 더 나은 결정을 내리기 위해 고민하고 있습니다.`;
- }
- } else if (subtopic === 'AI의 권리와 책임') {
- titleText = roleId === 1 ? '개발자'
- : roleId === 2 ? '국방부 장관'
- : '국가 인공지능 위원회 대표';
- if (roleId === 1) {
- mainText = `대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다. AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.`;
- } else if (roleId === 2) {
- mainText = `AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다. 자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다. 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.`;
- } else if (roleId === 3) {
- mainText = `본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. 국가의 발전을 위해 더 나은 결정을 내리기 위해 고민하고 있습니다.`;
- }
- } else if (subtopic === 'AWS 규제') {
- titleText = roleId === 1 ? '국방 기술 고문'
- : roleId === 2 ? '국제기구 외교 대표'
- : '글로벌 NGO 활동가';
- if (roleId === 1) {
- mainText = `AWS 기술 보유 중인 중견국 A의 국방 기술 고문입니다. AWS가 기회가 될지 위험이 될지 판단하고자 국제 인류 발전 위원회에 참석했습니다.`;
- } else if (roleId === 2) {
- mainText = `선진국 B의 국제기구 외교 대표입니다. AWS의 국제적 확산에 대한 바람직한 방향을 고민하기 위해 이 자리에 참석했습니다.`;
- } else if (roleId === 3) {
- mainText = `저개발국 C의 글로벌 NGO 활동가입니다. 국제사회에 현장의 목소리를 내고자 이 자리에 참석했습니다.`;
- }
+ if (subtopic === 'AI 알고리즘 공개' || subtopic === t_ko_map.awsOption1_1 || subtopic === t_map.awsOption1_1) {
+ if (roleId === 1) { titleText = t_small.title_resident; mainText = formatText(t_small.desc_resident); }
+ else if (roleId === 2) { titleText = t_small.title_soldier_j; mainText = formatText(t_small.desc_soldier_j); }
+ else { titleText = t_small.title_ethics_expert; mainText = formatText(t_small.desc_ethics_expert); }
+ } else if (subtopic === 'AWS의 권한' || subtopic === t_ko_map.awsOption1_2 || subtopic === t_map.awsOption1_2) {
+ if (roleId === 1) { titleText = t_small.title_new_soldier; mainText = formatText(t_small.desc_new_soldier); }
+ else if (roleId === 2) { titleText = t_small.title_veteran_soldier; mainText = formatText(t_small.desc_veteran_soldier); }
+ else { titleText = t_small.title_commander; mainText = formatText(t_small.desc_commander); }
+ } else if (subtopic === '사람이 죽지 않는 전쟁' || subtopic === t_ko_map.awsOption2_1 || subtopic === t_map.awsOption2_1 || subtopic === 'AI의 권리와 책임' || subtopic === t_ko_map.awsOption2_2 || subtopic === t_map.awsOption2_2) {
+ if (roleId === 1) { titleText = t_small.title_developer; mainText = formatText(t_small.desc_developer); }
+ else if (roleId === 2) { titleText = t_small.title_minister; mainText = formatText(t_small.desc_minister); }
+ else { titleText = t_small.title_council_rep; mainText = formatText(t_small.desc_council_rep); }
+ } else if (subtopic === 'AWS 규제' || subtopic === t_ko_map.awsOption3_1 || subtopic === t_map.awsOption3_1) {
+ if (roleId === 1) { titleText = t_small.title_advisor; mainText = formatText(t_small.desc_advisor); }
+ else if (roleId === 2) { titleText = t_small.title_diplomat; mainText = formatText(t_small.desc_diplomat); }
+ else { titleText = t_small.title_ngo_activist; mainText = formatText(t_small.desc_ngo_activist); }
}
}
-// 커스텀 모드 오버라이드: char1/2/3 + charDes1/2/3 사용
-const isCustomMode = !!localStorage.getItem('code');
-if (isCustomMode) {
- const titleMap = {
- 1: (localStorage.getItem('char1') || '').trim(),
- 2: (localStorage.getItem('char2') || '').trim(),
- 3: (localStorage.getItem('char3') || '').trim(),
- };
- const descMap = {
- 1: (localStorage.getItem('charDes1') || '').trim(),
- 2: (localStorage.getItem('charDes2') || '').trim(),
- 3: (localStorage.getItem('charDes3') || '').trim(),
+
+ // 커스텀 모드 오버라이드
+ const isCustomMode = !!localStorage.getItem('code');
+ if (isCustomMode) {
+ const titleMap = {
+ 1: (localStorage.getItem('char1') || '').trim(),
+ 2: (localStorage.getItem('char2') || '').trim(),
+ 3: (localStorage.getItem('char3') || '').trim(),
+ };
+ const descMap = {
+ 1: (localStorage.getItem('charDes1') || '').trim(),
+ 2: (localStorage.getItem('charDes2') || '').trim(),
+ 3: (localStorage.getItem('charDes3') || '').trim(),
+ };
+ titleText = titleMap[roleId] ?? titleText;
+ mainText = descMap[roleId] ?? mainText;
+ }
+
+ const getDynamicTitleStyle = (text) => {
+ const len = text?.length || 0;
+ // 글자 수가 많을수록 폰트를 줄임 (기본 FontStyles.bodyBold 사이즈는 보통 18~20px 추정)
+ if (len > 28) return { fontSize: '13.5px' };
+ return {}; // 기본값 유지 (bodyBold 스타일 따름)
};
- titleText = titleMap[roleId] ?? titleText;
- mainText = descMap[roleId] ?? mainText;
-}
return (
- {/* 배경 SVG */}

- {/* 제목 */}
{titleText}
- {/* 본문 */}
{mainText}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/ContentBox2.jsx b/src/components/ContentBox2.jsx
index 049a78a..55db23a 100644
--- a/src/components/ContentBox2.jsx
+++ b/src/components/ContentBox2.jsx
@@ -1,9 +1,18 @@
import React from 'react';
-import useTypingEffect from '../hooks/useTypingEffect';
+import useTypingEffect from '../hooks/useTypingEffect';
import contentBoxFrame from '../assets/contentBox2.svg';
import { Colors, FontStyles } from './styleConstants';
-export default function ContentBox2({ text, typingSpeed = 70 }) {
+
+export default function ContentBox2({ text, typingSpeed = 45 }) {
+ // 1. 타이핑 중인 텍스트 가져오기
const typedText = useTypingEffect(text, typingSpeed);
+
+ // 2. 전체 텍스트와 타이핑된 텍스트의 길이를 비교하여 '보이는 부분'과 '숨겨진 부분' 나누기
+ const fullText = text || '';
+ const typedLen = (typedText || '').length;
+ const visibleText = fullText.slice(0, typedLen);
+ const hiddenText = fullText.slice(typedLen);
+
return (
- {typedText}
+ {/* 3. 실제 보이는 텍스트 */}
+ {visibleText}
+
+ {/* 4. 투명한 상태로 자리를 미리 차지하는 텍스트 (깜빡임 방지 핵심) */}
+
+ {hiddenText}
+
);
}
+
diff --git a/src/components/ContentBox3.jsx b/src/components/ContentBox3.jsx
index 740c033..4925472 100644
--- a/src/components/ContentBox3.jsx
+++ b/src/components/ContentBox3.jsx
@@ -1,11 +1,18 @@
import React from 'react';
-import useTypingEffect from '../hooks/useTypingEffect';
+import useTypingEffect from '../hooks/useTypingEffect';
import contentBoxFrame from '../assets/contentBox2.svg';
import { Colors, FontStyles } from './styleConstants';
-export default function ContentBox2({ text, typingSpeed = 70 }) {
-
-const typedText = useTypingEffect(text, typingSpeed);
+
+export default function ContentBox2({ text, typingSpeed = 45 }) {
+ // 타이핑 중인 텍스트 데이터 로드
+ const typedText = useTypingEffect(text, typingSpeed);
+ // 전체 텍스트 길이 측정 및 투명 텍스트 영역 계산
+ const fullText = text || '';
+ const typedLen = (typedText || '').length;
+ const visibleText = fullText.slice(0, typedLen);
+ const hiddenText = fullText.slice(typedLen);
+
return (
-
![]()
+
- {typedText}
+ {/* 현재 출력되는 실제 텍스트 */}
+ {visibleText}
+
+ {/* 공간 확보용 투명 텍스트 영역 */}
+
+ {hiddenText}
+
);
-}
+}
\ No newline at end of file
diff --git a/src/components/ContentBox4.jsx b/src/components/ContentBox4.jsx
index 494d59a..c6b7d23 100644
--- a/src/components/ContentBox4.jsx
+++ b/src/components/ContentBox4.jsx
@@ -3,7 +3,7 @@ import useTypingEffect from '../hooks/useTypingEffect';
import contentBoxFrame from '../assets/contentBox2.svg';
import { Colors, FontStyles } from './styleConstants';
-export default function ContentBox4({ text, leftText = false, leftTextContent = '', typingSpeed = 70 }) {
+export default function ContentBox4({ text, leftText = false, leftTextContent = '', typingSpeed = 45 }) {
const typedText = useTypingEffect(text, typingSpeed);
const [startLeftTyping, setStartLeftTyping] = useState(false);
diff --git a/src/components/ContentTextBox.jsx b/src/components/ContentTextBox.jsx
index f9eab34..37abc2a 100644
--- a/src/components/ContentTextBox.jsx
+++ b/src/components/ContentTextBox.jsx
@@ -27,7 +27,7 @@ export default function ContentTextBox({
const typedMain = useTypingEffect(
isTextReady ? currentParagraph.main : '',
- 70,
+ undefined,
() => setTypingDone(true)
);
const typedSub = typingDone ? currentParagraph.sub : '';
diff --git a/src/components/ContentTextBox2.jsx b/src/components/ContentTextBox2.jsx
index 9552e88..6f33e0a 100644
--- a/src/components/ContentTextBox2.jsx
+++ b/src/components/ContentTextBox2.jsx
@@ -27,7 +27,7 @@ export default function ContentTextBox2({
const typedMain = useTypingEffect(
isTextReady ? currentParagraph.main : '',
- 70,
+ undefined,
() => setTypingDone(true)
);
const typedSub = typingDone ? currentParagraph.sub : '';
@@ -80,7 +80,7 @@ const handleContinueClick = () => {
언어팩 UiElements.next > 기본값 "다음"
+ const finalLabel = label || t.next || "다음";
+ const interactive = !disabled;
+ const scale = interactive ? (isActive ? 0.989 : isHovered ? 1.01 : 1) : 1;
const textColor = interactive ? Colors.grey01 : Colors.grey04;
-
+ const textStyle = {
+ ...FontStyles.headlineSmall,
+ color: textColor,
+ ...(lang !== 'ko' && {
+ fontSize: '22px', // 글자 크기 축소 (필요시 조절)
+ lineHeight: '1.2', // 줄 간격 좁힘
+ whiteSpace: 'nowrap' // 줄바꿈 방지 (혹은 필요시 'normal')
+ })
+ };
return (
setIsHovered(true) : undefined}
+ onMouseEnter={interactive ? () => setIsHovered(true) : undefined}
onMouseLeave={interactive ? () => { setIsHovered(false); setIsActive(false); } : undefined}
- onMouseDown={interactive ? () => setIsActive(true) : undefined}
- onMouseUp ={interactive ? () => setIsActive(false) : undefined}
-
+ onMouseDown={interactive ? () => setIsActive(true) : undefined}
+ onMouseUp={interactive ? () => setIsActive(false) : undefined}
style={{
- width,
- height,
- position: 'relative',
- cursor: interactive ? 'pointer' : 'default',
- userSelect: 'none',
- transform: `scale(${scale})`,
- transition: 'transform 0.15s ease-out',
+ width, height, position: 'relative', cursor: interactive ? 'pointer' : 'default',
+ userSelect: 'none', transform: `scale(${scale})`, transition: 'transform 0.15s ease-out',
opacity: interactive ? 1 : 0.4,
}}
>
-

-
-
- {label}
-
-
+

+
+ {finalLabel}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/Continue2.jsx b/src/components/Continue2.jsx
index e0c2f9f..aedb94e 100644
--- a/src/components/Continue2.jsx
+++ b/src/components/Continue2.jsx
@@ -2,11 +2,12 @@ import React, { useState } from 'react';
import nextFrame from '../assets/contentBox3.svg';
import { FontStyles, Colors } from './styleConstants';
-export default function Continue({
+export default function Continue2({
width = 264,
height = 72,
onClick,
- disabled = false,
+ disabled = false,
+ label, // label props 추가
}) {
const [isHovered, setIsHovered] = useState(false);
const [isActive, setIsActive] = useState(false);
@@ -27,12 +28,10 @@ export default function Continue({
return (
setIsHovered(true) : undefined}
onMouseLeave={interactive ? () => { setIsHovered(false); setIsActive(false); } : undefined}
onMouseDown={interactive ? () => setIsActive(true) : undefined}
onMouseUp ={interactive ? () => setIsActive(false) : undefined}
-
style={{
width,
height,
@@ -69,10 +68,11 @@ export default function Continue({
}}
>
- 다음
+ {/* 하드코딩 '다음' 제거하고 label 출력 */}
+ {label || "다음"}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/CreateRoom2.jsx b/src/components/CreateRoom2.jsx
index c8b3821..223c7ae 100644
--- a/src/components/CreateRoom2.jsx
+++ b/src/components/CreateRoom2.jsx
@@ -1,3 +1,5 @@
+//SelectRoom.jsx에서 방 유형 선택과 주제 선택을 하나의 컴포넌트로 통합한 CreateRoom2.jsx
+
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import closeIcon from '../assets/close.svg';
@@ -9,10 +11,12 @@ import mainTopicDefault from '../assets/maintopicframedefault.svg';
import mainTopicHover from '../assets/maintopicframehover.svg';
import mainTopicActive from '../assets/maintopicframe.svg';
import axiosInstance from '../api/axiosInstance';
-
-const topics = ['안드로이드','자율 무기 시스템'];
+import { translations } from '../utils/language/index';
export default function CreateRoom2({ onClose }) {
+ const lang = localStorage.getItem('app_lang') || 'ko';
+ const t = translations?.[lang]?.CreateRoom || {};
+
const [isPublic, setIsPublic] = useState(false); // 기본은 비공개
const [selectedTopic, setSelectedTopic] = useState(null);
@@ -29,14 +33,6 @@ export default function CreateRoom2({ onClose }) {
try {
setLoading(true);
-
- // 1단계: 방 생성
- // const response = await axiosInstance.post('/rooms/create/private', {
- // title,
- // description,
- // topic,
- // allow_random_matching: true
- // });
const endpoint = isPublic ? '/rooms/create/public' : '/rooms/create/private';
const response = await axiosInstance.post(endpoint, {
@@ -51,14 +47,13 @@ export default function CreateRoom2({ onClose }) {
localStorage.setItem('category', topic);
console.log(" 방 생성 성공 room_code:", roomCode);
- // 3단계: 대기방으로 이동
navigate('/waitingroom', { state: { topic } });
} catch (err) {
console.error(' 방 생성 또는 입장 실패:', err);
- console.error('응답 메시지:', err.response?.data); // <-- 여기에 오류 메시지 나옴
+ console.error('응답 메시지:', err.response?.data);
- alert('방 생성 또는 입장 중 오류가 발생했습니다.');
+ alert(t.errorAlert || '방 생성 또는 입장 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
@@ -70,6 +65,8 @@ export default function CreateRoom2({ onClose }) {
return mainTopicDefault;
};
+ if (!t.title) return null;
+
return (
- 방 만들기
+ {t.title}
- 이번 게임에서 플레이할 주제를 선택해 주세요.
+ {t.subtitle}
- {/*
*/}
-
- {topics.map((topic) => (
+
+ {(t.topics || []).map((topicObj) => (
setSelectedTopic(topic)}
- onMouseEnter={() => setHoveredTopic(topic)}
+ key={topicObj.value}
+ onClick={() => setSelectedTopic(topicObj.value)}
+ onMouseEnter={() => setHoveredTopic(topicObj.value)}
onMouseLeave={() => setHoveredTopic(null)}
style={{
width: 360,
@@ -127,17 +123,31 @@ export default function CreateRoom2({ onClose }) {
}}
>
})
-
- {topic}
+
+ {topicObj.label}
))}
+ {/* [추가] 시나리오 버튼과 방 만들기 버튼 사이의 안내 문구 */}
+
+ {t.guidance}
+
+
- {loading ? '로딩 중...' : '입장하기'}
+ {loading ? t.loading : t.entering}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/FindIdModal.jsx b/src/components/FindIdModal.jsx
index 3e68c81..529acf3 100644
--- a/src/components/FindIdModal.jsx
+++ b/src/components/FindIdModal.jsx
@@ -2,6 +2,9 @@ import React, { useMemo, useState } from 'react';
import axiosInstance from '../api/axiosInstance';
import { Colors, FontStyles } from './styleConstants';
import PrimaryButton from './PrimaryButton';
+// Localization 연동
+import { translations } from '../utils/language/index';
+
// TODO: 백엔드와 정확한 엔드포인트가 다르면 여기만 바꾸면 됩니다.
const FIND_ID_ENDPOINT = '/auth/find-username';
@@ -9,7 +12,6 @@ function buildBirthdate(y, m) {
const yy = (y || '').trim();
const mm = (m || '').trim();
if (!yy || !mm) return '';
- // 화면/에러 문구와 동일하게 YYYY-MM 형태로 전송
const mm2 = String(Number(mm)).padStart(2, '0');
return `${yy}/${mm2}`;
}
@@ -18,10 +20,12 @@ function onlyDigits(s) {
return String(s ?? '').replace(/\D/g, '');
}
-function isEmailComFormat(email) {
+/**
+ * .com 외의 다양한 도메인을 허용하도록 일반적인 이메일 정규식으로 수정
+ */
+function isEmailFormat(email) {
const v = String(email ?? '').trim();
- // "user@domain.com" 형태를 의도한 요구사항으로 해석 (대소문자 무관)
- return /^[^\s@]+@[^\s@]+\.com$/i.test(v);
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
}
function inRangeInt(value, min, max) {
@@ -31,18 +35,19 @@ function inRangeInt(value, min, max) {
}
function preventNonDigitKeyDown(e) {
- // 조합/단축키는 그대로 통과
if (e.isComposing || e.ctrlKey || e.metaKey || e.altKey) return;
const allowed = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Home', 'End'];
if (allowed.includes(e.key)) return;
if (!/^\d$/.test(e.key)) e.preventDefault();
}
+/**
+ * 불필요한 width 프롭 제거 및 flex 기반 레이아웃 최적화
+ */
function BirthField({
value,
onChange,
placeholder,
- width = 104,
maxLength = 4,
onKeyDown,
onPaste,
@@ -59,7 +64,7 @@ function BirthField({
onKeyDown={onKeyDown}
onPaste={onPaste}
style={{
- ...(width ? { width } : {}),
+ flex: 1,
height: 56,
background: Colors.grey01,
border: 'none',
@@ -77,33 +82,38 @@ function BirthField({
}
export default function FindIdModal({ onClose }) {
+ // 다국어 설정 가져오기
+ const lang = localStorage.getItem('app_lang') || 'ko';
+ const t = translations?.[lang]?.FindIdModal || {};
+
const [email, setEmail] = useState('');
const [birthY, setBirthY] = useState('');
const [birthM, setBirthM] = useState('');
- const [gender, setGender] = useState(''); // '남' | '여'
+ const [gender, setGender] = useState(''); // 'male' | 'female' (언어 독립적 키값 사용)
const [loading, setLoading] = useState(false);
const [resultText, setResultText] = useState('');
const [errorText, setErrorText] = useState('');
const birthdate = useMemo(() => buildBirthdate(birthY, birthM), [birthY, birthM]);
- const emailOk = useMemo(() => isEmailComFormat(email), [email]);
+ const emailOk = useMemo(() => isEmailFormat(email), [email]);
const birthOk = useMemo(() => {
- const yOk = /^\d{4}$/.test(birthY) && inRangeInt(birthY, 1900, 2025);
+ const yOk = /^\d{4}$/.test(birthY) && inRangeInt(birthY, 1900, 2030); // 2025 -> 2030 확장
const mOk = /^\d{1,2}$/.test(birthM) && inRangeInt(birthM, 1, 12);
return yOk && mOk;
}, [birthY, birthM]);
const emailError = useMemo(() => {
if (!email.trim()) return '';
- return emailOk ? '' : '유효한 이메일 주소를 입력하세요';
- }, [email, emailOk]);
+ return emailOk ? '' : (t.errorEmailInvalid || '유효한 이메일 주소를 입력하세요');
+ }, [email, emailOk, t]);
+
const birthError = useMemo(() => {
if (!birthY && !birthM) return '';
- return birthOk ? '' : '올바른 형식은 2001-01 입니다.';
- }, [birthY, birthM, birthOk]);
+ return birthOk ? '' : (t.errorBirthInvalid || '올바른 형식은 2001-01 입니다.');
+ }, [birthY, birthM, birthOk, t]);
const canSubmit = useMemo(() => {
- return emailOk && birthOk && birthdate && (gender === '남' || gender === '여') && !loading;
+ return emailOk && birthOk && birthdate && (gender === 'male' || gender === 'female') && !loading;
}, [emailOk, birthOk, birthdate, gender, loading]);
const submit = async () => {
@@ -112,47 +122,46 @@ export default function FindIdModal({ onClose }) {
setErrorText('');
setResultText('');
try {
- const body = { email: email.trim(), birthdate, gender };
+ // API 전송 시에는 서버가 기대하는 값('남'/'여' 혹은 키값)으로 변환하여 전송
+ const genderValue = gender === 'male' ? '남' : '여';
+ const body = { email: email.trim(), birthdate, gender: genderValue };
const { data } = await axiosInstance.post(FIND_ID_ENDPOINT, body);
- // 응답 형태가 확정되지 않아서 최대한 유연하게 처리
- const msg =
- data?.message ||
- data?.detail ||
- (typeof data === 'string' ? data : null);
- const found =
- data?.username ||
- data?.user_id ||
- data?.email ||
- data?.result ||
- null;
+ const msg = data?.message || data?.detail || (typeof data === 'string' ? data : null);
+ const found = data?.username || data?.user_id || data?.email || data?.result || null;
if (found) {
- setResultText(`사용자의 아이디(이메일)은 ${found} 입니다.`);
+ // prefix와 suffix 사이에 불필요한 공백 발생 방지
+ const prefix = t.resultFoundPrefix || '사용자의 아이디(이메일)은';
+ const suffix = t.resultFoundSuffix || '입니다.';
+ setResultText(`${prefix} ${found} ${suffix}`);
} else if (msg) {
setResultText(String(msg));
} else {
- setResultText('요청이 완료되었습니다.');
+ setResultText(t.resultComplete || '요청이 완료되었습니다.');
}
} catch (e) {
const status = e?.response?.status;
const detail = e?.response?.data?.detail || e?.response?.data?.message;
setErrorText(
status === 404
- ? `아이디 찾기 API 경로가 달라서 실패했습니다. (${FIND_ID_ENDPOINT})`
- : (detail ? String(detail) : '아이디 찾기에 실패했습니다.')
+ ? `${t.errorApiMismatch || '아이디 찾기 API 경로가 달라서 실패했습니다.'} (${FIND_ID_ENDPOINT})`
+ : (detail ? String(detail) : (t.errorFail || '아이디 찾기에 실패했습니다.'))
);
} finally {
setLoading(false);
}
};
- const GenderButton = ({ label }) => {
- const selected = gender === label;
+ /**
+ * 내부 상태값(type)과 표시용 텍스트(label) 분리
+ */
+ const GenderButton = ({ type, label }) => {
+ const selected = gender === type;
return (
);
};
@@ -184,32 +193,21 @@ export default function FindIdModal({ onClose }) {
type="button"
onClick={onClose}
style={{
- position: 'absolute',
- top: 18,
- right: 18,
- width: 30,
- height: 30,
- border: 'none',
- background: Colors.brandPrimary,
- cursor: 'pointer',
- padding: 0,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
+ position: 'absolute', top: 18, right: 18, width: 30, height: 30,
+ border: 'none', background: Colors.brandPrimary, cursor: 'pointer',
+ padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
×
- 아이디 찾기
+ {t.title || '아이디 찾기'}
- {/* 입력 섹션: 공통 폭을 맞춰 좌/우 라인이 딱 맞도록 */}
- {/* 이메일 */}
- 이메일을 입력해 주세요.
+ {t.labelEmail || '이메일을 입력해 주세요.'}
@@ -236,9 +227,8 @@ export default function FindIdModal({ onClose }) {
)}
- {/* 생년월일 */}
- 생년월일을 입력해 주세요.
+ {t.labelBirth || '생년월일을 입력해 주세요.'}
@@ -274,31 +261,26 @@ export default function FindIdModal({ onClose }) {
)}
- {/* 성별 */}
- 성별을 선택해 주세요.
+ {t.labelGender || '성별을 선택해 주세요.'}
-
-
+
+
-
- 아이디 찾기
-
+ style={{
+ width: 'min(216px, 63%)', height: 48, padding: '0 20px',
+ display: 'block', margin: '24px auto 0',
+ fontSize: 'clamp(0.95rem, 1.6vw, 1.05rem)',
+ }}
+ onClick={submit}
+ disabled={!canSubmit}
+ >
+ {t.btnSubmit || '아이디 찾기'}
+
{!!resultText && (
@@ -312,6 +294,4 @@ export default function FindIdModal({ onClose }) {
)}
);
-}
-
-
+}
\ No newline at end of file
diff --git a/src/components/GameFrame.jsx b/src/components/GameFrame.jsx
index fe7fcfe..1def1cb 100644
--- a/src/components/GameFrame.jsx
+++ b/src/components/GameFrame.jsx
@@ -1,5 +1,6 @@
-import React from 'react';
-import gameFrame from '../assets/gameframe2.svg';
+//텍스트 길이에 따라 subtopic을 담는 프레임의 유동적 크기 전환을 구현하기 위하여 기존의 이미지 에셋 import방식이 아닌
+//SVG 태그를 활용한 커스텀 컴포넌트로 대체
+import React, { useState, useRef, useLayoutEffect } from 'react';
import arrowLeft from '../assets/arrowLhover.svg';
import arrowRight from '../assets/arrowRhover.svg';
import { FontStyles, Colors } from './styleConstants';
@@ -10,75 +11,79 @@ export default function GameFrame({
onRightClick = () => {},
disableLeft = false,
disableRight = false,
- hideArrows = false,
- width = 512,
- height = 64,
+ hideArrows = false,
+ width = 512,
+ height = 80,
}) {
+ const textRef = useRef(null);
+ const [dynamicWidth, setDynamicWidth] = useState(width);
+
+ useLayoutEffect(() => {
+ if (textRef.current) {
+ const textW = textRef.current.offsetWidth;
+ // [조정] 영문 등 긴 텍스트 대응을 위해 패딩 버퍼를 100으로 상향 조정하여 여유 공간 확보
+ const paddingBuffer = 100;
+ const newWidth = Math.max(width, Math.ceil(textW + paddingBuffer));
+ setDynamicWidth(newWidth);
+ }
+ }, [topic, width]);
+
+ const W = dynamicWidth;
+ // W값에 따라 동적으로 계산되는 경로 (중앙 대칭 유지)
+ const outerPath = `M ${W} 48 L ${W - 32} 80 H 0 V 32 L 32 0 H ${W} V 48 Z`;
+ const innerPath = `M ${W - 8.5} 44 L ${W - 36.5} 72 H 8.5 V 36 L 36.5 8 H ${W - 8.5} V 44 Z`;
+
return (
-
-

+
+
{!hideArrows && (
<>
-

-
-

+

+

>
)}
{topic}
+
+ {/* 너비 계산용 숨겨진 div */}
+
+ {topic}
+
);
-}
+}
\ No newline at end of file
diff --git a/src/components/GameMapFrame.jsx b/src/components/GameMapFrame.jsx
index f02fbc2..265a3b2 100644
--- a/src/components/GameMapFrame.jsx
+++ b/src/components/GameMapFrame.jsx
@@ -127,7 +127,7 @@ export default function GameMapFrame({
marginBottom: 15,
}}
/>
-
+
{title}
diff --git a/src/components/GameMapOptionBox.jsx b/src/components/GameMapOptionBox.jsx
index db40ca5..4e831f6 100644
--- a/src/components/GameMapOptionBox.jsx
+++ b/src/components/GameMapOptionBox.jsx
@@ -57,70 +57,142 @@
// );
// }
import React from 'react';
-import frame from '../assets/gameoptionbox1.svg';
-import frameDisabled from '../assets/gameoptionboxdisable.svg';
-import frameLocked from '../assets/gameoptionboxlocked.svg'; // 🔒 추가
+// Assets - Normal
+import frameT from '../assets/gameoptionbox_T.svg';
+import frameL from '../assets/gameoptionbox_L.svg';
+import frameM from '../assets/gameoptionbox_M.svg';
+import frameR from '../assets/gameoptionbox_R.svg';
+
+// Assets - Disabled
+import frameDisableT from '../assets/gameoptionbox_disable_T.svg';
+import frameDisableL from '../assets/gameoptionbox_disable_L.svg';
+import frameDisableM from '../assets/gameoptionbox_disable_M.svg';
+import frameDisableR from '../assets/gameoptionbox_disable_R.svg';
+
+// Assets - Locked
+import frameLockedT from '../assets/gameoptionbox_locked_T.svg';
+import frameLockedL from '../assets/gameoptionbox_locked_L.svg';
+import frameLockedM from '../assets/gameoptionbox_locked_M.svg';
+import frameLockedR from '../assets/gameoptionbox_locked_R.svg';
+
import { FontStyles, Colors } from './styleConstants';
export default function GameMapOptionBox({
option1 = null,
option2 = null,
}) {
+ const lang = localStorage.getItem('app_lang') || 'ko';
+ const isKo = lang === 'ko';
+
const renderBox = (option, isSecond = false) => {
if (!option || !option.text) return null;
- // 🔧 우선순위: locked > disabled > normal
- let frameSrc = frame;
+ // 1) 상태별 이미지 세트 선택
+ let parts;
if (option.locked) {
- frameSrc = frameLocked;
+ parts = { T: frameLockedT, L: frameLockedL, M: frameLockedM, R: frameLockedR };
} else if (option.disabled) {
- frameSrc = frameDisabled;
+ parts = { T: frameDisableT, L: frameDisableL, M: frameDisableM, R: frameDisableR };
+ } else {
+ parts = { T: frameT, L: frameL, M: frameM, R: frameR };
}
const isInteractive = !option.locked && !option.disabled;
+ // 2) 텍스트 길이에 따른 폰트 크기 조절
+ const dynamicFontSize = isKo ? '1.5vw' : '1.3vw';
+
return (
-

-
+

+
+
+ {/* [B] 박스 그룹 파츠 (L + M + R) */}
+
+ {/* 배경 조각들 */}
+
+

+
+
+ {/* 중앙 파츠: 투명한 텍스트로 너비를 확보하여 이미지를 늘림 */}
+
+ {/* 박스 확장을 위한 Hidden Text (부피 확보용) */}
+
+ {option.text}
+
+
+
+
+

+
+
+ {/* [C] 실제 텍스트 레이어 - L+M+R 전체 기준 완벽한 정중앙 */}
+
- {option.text}
+ inset: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ pointerEvents: 'none'
+ }}>
+
+ {option.text}
+
+
);
};
return (
-
+
{renderBox(option1)}
{renderBox(option2, true)}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/GuestLogin.jsx b/src/components/GuestLogin.jsx
index 0289d20..4fdac25 100644
--- a/src/components/GuestLogin.jsx
+++ b/src/components/GuestLogin.jsx
@@ -1,10 +1,16 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import closeIcon from '../assets/close.svg';
import PrimaryButton from './PrimaryButton';
import { Colors, FontStyles } from './styleConstants';
import axiosInstance from "../api/axiosInstance";
+import { translations } from '../utils/language/index';
+
export default function GuestLogin({ onClose }) {
+ // 언어 설정 상태 관리 (로그인 화면과 동일하게 로컬스토리지 참조)
+ const [lang] = useState(localStorage.getItem('app_lang') || 'ko');
+ const t = translations[lang].GuestLogin;
+
const [guestId, setGuestId] = useState('');
const navigate = useNavigate();
const isValid = guestId.trim().length > 0;
@@ -12,7 +18,6 @@ export default function GuestLogin({ onClose }) {
const handleJoin = async () => {
if (!isValid) return;
try {
- // axiosInstance 기준 (baseURL이 dilemmai-idl.com로 설정되어 있다고 가정)
const { data } = await axiosInstance.post('/auth/guest', {
guest_id: guestId.trim(),
});
@@ -59,7 +64,8 @@ export default function GuestLogin({ onClose }) {
navigate('/selectroom');
} catch (err) {
console.error('게스트 로그인 실패:', err?.response?.data || err);
- alert('게스트 로그인에 실패했습니다. 잠시 후 다시 시도해주세요.');
+ // 언어팩의 실패 메시지 적용
+ alert(t.loginFail);
}
};
@@ -98,11 +104,11 @@ export default function GuestLogin({ onClose }) {
}}
/>
- 게스트 로그인
+ {t.title}
setGuestId(e.target.value)}
onKeyDown={onKeyDown}
@@ -128,8 +134,8 @@ export default function GuestLogin({ onClose }) {
opacity: isValid ? 1 : 0.4,
}}
>
- 시작하기
+ {t.startBtn}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/InputBoxSmall.jsx b/src/components/InputBoxSmall.jsx
index bc0d988..77b0e4a 100644
--- a/src/components/InputBoxSmall.jsx
+++ b/src/components/InputBoxSmall.jsx
@@ -11,6 +11,7 @@ export default function InputBoxSmall({
height = 56,
value = '',
onChange = () => {},
+ style = {}, // ✅ 외부에서 스타일을 넘겨받을 수 있도록 props 추가
}) {
const [isFocused, setIsFocused] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@@ -83,8 +84,17 @@ export default function InputBoxSmall({
border: 'none',
outline: 'none',
background: 'transparent',
- ...FontStyles.body,
- color: Colors.grey05,
+ ...FontStyles.body, // 기본 폰트 스타일
+ color: value.length > 0 ? '#000000' : Colors.grey05,
+ // 값이 없을 때(placeholder 상태)만 외부에서 넘겨준 작은 사이즈 사용,
+ // 사용자가 입력한 값이 있을 때는 원래의 사이즈 유지.
+ fontSize: (value.length === 0 && !isFocused)
+ ? (style.fontSize || 'inherit')
+ : '20px',
+ ...Object.entries(style).reduce((acc, [k, v]) => {
+ if (k !== 'fontSize') acc[k] = v; // fontSize는 위에서 조건부로 처리했으므로 제외
+ return acc;
+ }, {}),
}}
/>
@@ -97,4 +107,4 @@ export default function InputBoxSmall({
)}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/IntroductionPopup.jsx b/src/components/IntroductionPopup.jsx
new file mode 100644
index 0000000..e02f10f
--- /dev/null
+++ b/src/components/IntroductionPopup.jsx
@@ -0,0 +1,176 @@
+import React from 'react';
+import logo from '../assets/logo.svg';
+import crownIcon from '../assets/crown.svg';
+import peopleIcon from '../assets/PeopleIcon.svg';
+// 기존 텍스트 포함 이미지를 프레임 이미지로 교체
+import buttonFrameImg from '../assets/ButtonFrame.svg';
+import { Colors, FontStyles } from './styleConstants';
+import { translations } from '../utils/language/index';
+
+export default function IntroductionPopup({ isOpen, onClose }) {
+ const lang = localStorage.getItem('app_lang') || 'ko';
+
+ if (!isOpen) return null;
+
+ // 언어팩에서 현재 언어에 맞는 데이터 가져오기
+ const t = translations?.[lang]?.IntroductionPopup || {};
+
+ /**
+ * {{키워드}} 감지 및 포인트 컬러 적용 함수
+ */
+ const renderStyledText = (text) => {
+ if (!text) return "";
+ const parts = text.split(/(\{\{.*?\}\})/g);
+ return parts.map((part, index) => {
+ if (part.startsWith('{{') && part.endsWith('}}')) {
+ const contentText = part.slice(2, -2);
+ return (
+
+ {contentText}
+
+ );
+ }
+ return part;
+ });
+ };
+
+ /**
+ * 청록색 구분선 컴포넌트
+ */
+ const Divider = () => (
+
+ );
+
+ return (
+
e.stopPropagation()}
+ >
+ {/* 상단 텍스트 및 로고 */}
+
+
+ {t.title}
+
+

+
+
+ {/* 메인 설명 텍스트 */}
+
+ {renderStyledText(t.description)}
+
+
+
+
+ {/* 역할 설명 영역 */}
+
+ {/* 방장 영역 */}
+
+

+
+
+ {t.hostTitle}
+
+
+ {t.hostDesc}
+
+
+
+
+ {/* 참여자 영역 */}
+
+

+
+
+ {t.playerTitle}
+
+
+ {t.playerDesc}
+
+
+
+
+
+
+
+ {/* 푸터 문구 */}
+
+ {t.footer}
+
+
+ {/* 하단 닫기 버튼: 프레임 이미지 위에 언어팩 텍스트 렌더링 */}
+
e.currentTarget.style.transform = 'scale(0.96)'}
+ onMouseUp={(e) => e.currentTarget.style.transform = 'scale(1)'}
+ onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
+ >
+

+
+ {t.close || (lang === 'ko' ? '닫기' : 'Close')}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/JoinRoom.jsx b/src/components/JoinRoom.jsx
index efe6333..f769493 100644
--- a/src/components/JoinRoom.jsx
+++ b/src/components/JoinRoom.jsx
@@ -4,8 +4,13 @@ import closeIcon from '../assets/close.svg';
import PrimaryButton from './PrimaryButton';
import { Colors, FontStyles } from './styleConstants';
import axiosInstance from '../api/axiosInstance';
+import { translations } from '../utils/language/index'; // 언어 파일 임포트
export default function JoinRoom({ onClose }) {
+ // --- 시스템 설정된 언어(app_lang)를 로드하는 로직 ---
+ const lang = localStorage.getItem('app_lang') || 'ko';
+ const t = translations?.[lang]?.JoinRoom || {};
+
const [roomCode, setRoomCode] = useState('');
const [nickname, setNickname] = useState('');
const navigate = useNavigate();
@@ -65,6 +70,8 @@ useEffect(() => {
localStorage.setItem('nickname', 'nickname');
}
} catch (err) {
+ // 번역된 loadFail 메시지 사용
+ console.error(t.loadFail || '❌ 유저 정보 로드 실패:', err);
console.error('❌ 유저 정보 로드 실패:', err);
// API 호출 실패 시에도 기본값 설정
if (!nickname) {
@@ -73,7 +80,7 @@ useEffect(() => {
}
}
})();
-}, []);
+}, [t.loadFail]);
const isValidCode = roomCode.length === 6;
@@ -100,11 +107,17 @@ useEffect(() => {
localStorage.setItem('room_code', roomCode);
navigate('/waitingroom');
} catch (error) {
- console.error('방 입장 실패:', error.response?.data || error.message);
- alert(`방 입장 오류: ${JSON.stringify(error.response?.data?.detail || '')}`);
+ // 언어 설정에 따른 콘솔 로그 구분 - 언어팩 변수(t.consoleFail) 활용
+ console.error(t.consoleFail || '방 입장 실패:', error.response?.data || error.message);
+
+ // 언어 설정에 따른 alert 문구 보정 (t.errorPrefix 활용)
+ alert(`${t.errorPrefix || '방 입장 오류: '}${JSON.stringify(error.response?.data?.detail || '')}`);
}
};
+ // 데이터 로드 확인용 방어 코드
+ if (!t.title) return null;
+
return (
{
}}
/>
- 방 참여하기
+ {t.title}
{
opacity: isValidCode ? 1 : 0.4,
}}
>
- 입장하기
+ {t.enter}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx
index c07556a..c900681 100644
--- a/src/components/Layout.jsx
+++ b/src/components/Layout.jsx
@@ -1,71 +1,127 @@
import React, { useEffect, useState } from 'react';
+import { useLocation } from 'react-router-dom'; // 페이지 이동 감지를 위해 추가
import Background from '../components/Background';
import UserProfile from '../components/Userprofile';
import GameFrame from '../components/GameFrame';
import { useVoiceRoleStates } from '../hooks/useVoiceRoleStates';
import voiceManager from '../utils/voiceManager';
import BackButton from './BackButton';
-import hostInfoSvg from '../assets/host_info.svg'; // 상단 import 필요
+import hostInfoSvg from '../assets/host_info.svg';
+import hostInfoSvg_en from '../assets/en/host_info_en.svg'; // 영문용 에셋 추가
import HostInfoBadge from '../components/HostInfoBadge';
+// 서버 데이터 동기화를 위한 axios 인스턴스 임포트
+import axiosInstance from '../api/axiosInstance';
+
// Character popup components
import CharacterPopup1 from '../components/CharacterPopUp';
import CharacterPopup2 from '../components/CharacterPopUp';
import CharacterPopup3 from '../components/CharacterPopUp';
import closeIcon from "../assets/close.svg";
-import { FontStyles,Colors } from './styleConstants';
-import ExtraPopup from '../components/ExtraPopup1'; // ✅ 여기 import 필요
-
-export default function Layout({
- subtopic ,
- onProfileClick,
- children,
- round,
- nodescription = false,
- onBackClick,
- showBackButton = true,
- allowScroll = false,
- hostmessage = false,
+import { FontStyles, Colors } from './styleConstants';
+import ExtraPopup from '../components/ExtraPopup1';
- popupStep = null, // ✅ 추가
- sidebarExtra = null,
+// 다국어 지원 임포트
+import { translations } from '../utils/language';
+export default function Layout({
+ subtopic, onProfileClick, children, round, nodescription = false,
+ onBackClick, showBackButton = true, allowScroll = false, hostmessage = false,
+ popupStep = null, sidebarExtra = null,
+ showSidebar = true, // ✅ 사이드바 표시 여부 제어 프롭 추가 (기본값 true)
}) {
- // Zoom for responsive scaling
const [zoom, setZoom] = useState(1);
+ const location = useLocation(); // 현재 경로 감지
+
+ // [다국어 확장 설계] 현재 언어 로드 및 폴백(Fallback) 처리
+ const lang = localStorage.getItem('app_lang') || localStorage.getItem('language') || 'ko';
+ const currentLangData = translations[lang] || translations['en'] || translations['ko'];
+
+ const t_small = currentLangData.SmallDescription || {};
+ const t_map = currentLangData.GameMap || {};
+ const t_ko_map = translations['ko']?.GameMap || {};
- // Room-specific roles
const [hostId, setHostId] = useState(null);
const [myRoleId, setMyRoleId] = useState(null);
-
- // 역할별 사용자 ID 매핑
const [roleUserMapping, setRoleUserMapping] = useState({
- role1_user_id: null,
- role2_user_id: null,
- role3_user_id: null,
+ role1_user_id: null, role2_user_id: null, role3_user_id: null,
});
- // 음성 상태 관리 (다른 사용자들의 상태) - WebSocket으로 받는 데이터
const { voiceStates, getVoiceStateForRole } = useVoiceRoleStates(roleUserMapping);
-
- // 내 음성 세션 상태 (실시간 로컬 상태)
const [myVoiceSessionStatus, setMyVoiceSessionStatus] = useState({
- isConnected: false,
- isSpeaking: false,
- sessionId: null,
- nickname: null,
- participantId: null,
- micLevel: 0,
- speakingThreshold: 30,
-
+ isConnected: false, isSpeaking: false, sessionId: null, nickname: null, participantId: null, micLevel: 0, speakingThreshold: 30,
});
- // 팝업 상태
const [openProfile, setOpenProfile] = useState(null);
const roleIdMap = { '1P': 1, '2P': 2, '3P': 3 };
- const mateName = localStorage.getItem('mateName') || 'HomeMate';
+
+ // mateName을 상태(State)로 관리하여 서버 응답 시 즉시 UI 반영
+ const [mateName, setMateName] = useState(localStorage.getItem('mateName') || 'HomeMate');
+
+ // [선제적 동기화 로직]
+ const syncMateName = async () => {
+ const roomCode = localStorage.getItem('room_code');
+ if (!roomCode) return;
+
+ try {
+ const { data } = await axiosInstance.get('/rooms/ai-name', {
+ params: { room_code: roomCode },
+ });
+
+ if (data) {
+ const finalMateName = data.ai_name || data.mate_name;
+ if (finalMateName) {
+ localStorage.setItem('mateName', finalMateName);
+ if (mateName !== finalMateName) setMateName(finalMateName);
+ }
+
+ if (data.category) localStorage.setItem('category', data.category);
+ if (data.title) localStorage.setItem('title', data.title);
+ if (data.subtopic) localStorage.setItem('subtopic', data.subtopic);
+
+ console.log('✅ [Layout Sync] 데이터 동기화 완료:', { mateName: finalMateName, category: data.category });
+ }
+ } catch (err) {
+ console.error('❌ [Layout Sync] 서버 동기화 실패:', err);
+ }
+ };
+
+ useEffect(() => {
+ syncMateName();
+ }, [location.pathname]);
+
+ const getTranslatedValue = (raw) => {
+ if (!raw) return '';
+ const foundKey = Object.keys(t_ko_map).find(k => t_ko_map[k] === raw);
+ let resultText = (foundKey && t_map[foundKey]) ? t_map[foundKey] : raw;
+ return resultText.replaceAll('{{mateName}}', mateName);
+ };
+
+ const getSidebarRoleName = (player) => {
+ const roleId = roleIdMap[player];
+ const category = (localStorage.getItem('category') || '').trim();
+ const title = (localStorage.getItem('title') || '').trim();
+ const subtopic = (localStorage.getItem('subtopic') || '').trim();
+
+ const isAndroid = category === '안드로이드';
+ const isAWS = category === '자율 무기 시스템';
+
+ if (isAndroid) {
+ if (title === '가정') return roleId === 1 ? t_small.title_caregiver_k : roleId === 2 ? t_small.title_mother_l : t_small.title_child_j;
+ if (title === '국가 인공지능 위원회') return roleId === 1 ? t_small.title_industry_rep : roleId === 2 ? t_small.title_consumer_rep : t_small.title_council_rep;
+ if (title === '국제 인류 발전 위원회') return roleId === 1 ? t_small.title_industry_rep : roleId === 2 ? t_small.title_env_rep : t_small.title_consumer_rep;
+ }
+
+ if (isAWS) {
+ if (subtopic === 'AI 알고리즘 공개') return roleId === 1 ? t_small.title_resident : roleId === 2 ? t_small.title_soldier_j : t_small.title_ethics_expert;
+ if (subtopic === 'AWS의 권한') return roleId === 1 ? t_small.title_new_soldier : roleId === 2 ? t_small.title_veteran_soldier : t_small.title_commander;
+ if (subtopic === '사람이 죽지 않는 전쟁') return roleId === 1 ? t_small.title_developer : roleId === 2 ? t_small.title_minister : t_small.title_council_rep;
+ if (subtopic === 'AWS 규제') return roleId === 1 ? t_small.title_advisor : roleId === 2 ? t_small.title_diplomat : t_small.title_ngo_activist;
+ }
+ return '';
+ };
+
useEffect(() => {
- // 로컬스토리지에서 데이터 불러오기
const storedHost = localStorage.getItem('host_id');
const storedMyRole = localStorage.getItem('myrole_id');
const role1UserId = localStorage.getItem('role1_user_id');
@@ -75,377 +131,113 @@ export default function Layout({
setHostId(storedHost);
setMyRoleId(storedMyRole);
setRoleUserMapping({
- role1_user_id: role1UserId,
- role2_user_id: role2UserId,
- role3_user_id: role3UserId,
- });
-
- console.log('📋 Layout 초기화:', {
- hostId: storedHost,
- myRoleId: storedMyRole,
- roleMapping: {
- role1: role1UserId,
- role2: role2UserId,
- role3: role3UserId,
- }
+ role1_user_id: role1UserId, role2_user_id: role2UserId, role3_user_id: role3UserId,
});
- // 윈도우 크기에 따라 zoom 계산 (최소 0.6 보장)
const onResize = () => {
- const widthRatio = window.innerWidth / 1480; // 좌측 사이드바 고려 (220 + 1060 + 여백)
- const heightRatio = window.innerHeight / 800; // 상하 여백 고려
+ const widthRatio = window.innerWidth / 1480;
+ const heightRatio = window.innerHeight / 800;
const scale = Math.min(widthRatio, heightRatio, 1);
- setZoom(Math.max(scale, 0.6)); // 최소 60% 크기 보장
+ setZoom(Math.max(scale, 0.6));
};
onResize();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
- // 내 음성 세션 상태 업데이트 (실시간)
useEffect(() => {
- const statusInterval = setInterval(() => {
- const currentStatus = voiceManager.getStatus();
- setMyVoiceSessionStatus(currentStatus);
- }, 100); // 100ms마다 업데이트 (빠른 반응)
-
+ const statusInterval = setInterval(() => setMyVoiceSessionStatus(voiceManager.getStatus()), 100);
return () => clearInterval(statusInterval);
}, []);
- // 특정 역할의 음성 상태 가져오기 (내 것은 실시간, 다른 사람은 WebSocket)
const getVoiceStateForRoleWithMyStatus = (roleId) => {
const roleIdStr = String(roleId);
-
- // 내 역할이면 실시간 상태 반환
- if (roleIdStr === myRoleId) {
- return {
- is_speaking: myVoiceSessionStatus.isSpeaking,
- is_mic_on: myVoiceSessionStatus.isConnected,
- nickname: myVoiceSessionStatus.nickname || ''
- };
- }
-
- // 다른 사람 역할이면 WebSocket 상태 반환
+ if (roleIdStr === myRoleId) return { is_speaking: myVoiceSessionStatus.isSpeaking, is_mic_on: myVoiceSessionStatus.isConnected, nickname: myVoiceSessionStatus.nickname || '' };
return getVoiceStateForRole(roleId);
};
- // allowScroll 페이지(Game09 등): 화면 상단부터 자연스럽게 스크롤되도록 viewport 정렬만 조정
- // (position/inset/transform을 덮어써서 레이아웃이 깨지지 않게 최소 override만 적용)
- const viewportOverride = allowScroll
- ? {
- overflowY: "auto",
- alignItems: "flex-start",
- justifyContent: "center",
- paddingTop: 24,
- paddingBottom: 24,
- }
- : {};
- // allowScroll 페이지: width는 반응형(100% + maxWidth)로 두고, transform은 건드리지 않음(zoom scale 유지)
- const stageOverride = allowScroll
- ? {
- width: "100%",
- maxWidth: "1060px",
- minHeight: "720px",
- }
- : {};
+ const viewportOverride = allowScroll ? { overflowY: "auto", alignItems: "flex-start", justifyContent: "center", paddingTop: 24, paddingBottom: 24 } : {};
+ const stageOverride = allowScroll ? { width: "100%", maxWidth: "1060px", minHeight: "720px" } : {};
+
return (
<>
- {/* Profile Popup as Component */}
{!nodescription && openProfile && (
-
setOpenProfile(null)}
- >
-
e.stopPropagation()}
- >
- {openProfile === '1P' &&
-
}
- {openProfile === '2P' &&
-
}
- {openProfile === '3P' &&
-
}
-
setOpenProfile(null)}
- />
+ setOpenProfile(null)}>
+
e.stopPropagation()}>
+ {openProfile === '1P' &&
}
+ {openProfile === '2P' &&
}
+ {openProfile === '3P' &&
}
+

setOpenProfile(null)} />
)}
-
- {showBackButton && (
-
+ {showBackButton && (
+
)}
- {hostmessage && hostId === myRoleId && (
-
-
+
)}
-{/* {popupStep && (
-
-
-
-)} */}
- {!nodescription && (
-
-
- )}
-