Skip to content

Commit 4c73bca

Browse files
SOIVclaude
andcommitted
feat(i18n): 모듈 displayName·description 다국어 키 전환 및 언어 설정 서버 저장
- module.json: displayName·description을 i18n 키로 전환 (ledger:displayName, ledger:description) - 번역 파일(ko/en): displayName·description 키 추가 - ModuleManifest + parseModuleJson: description 필드 추가 - GET /core/modules/me: description·displayName 응답에 포함 - AppShell: t(mod.displayName) 으로 사이드바 모듈명 번역 적용 - HomeView: 모듈 카드에 displayName·description 표시 복원 - SettingsView: 모듈 목록에 displayName·description 표시, settings-module-desc 스타일 추가 - 005_add_user_language.sql: users.language 컬럼 추가 (DEFAULT 'en') - GET/PATCH /core/users/me/settings: 언어 설정 서버 저장·조회 API 구현 - 로그인 후 서버 언어 설정 로드, 저장 시 PATCH 호출 연동 - i18n 기본 언어 ko → en 변경 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bb0bb85 commit 4c73bca

14 files changed

Lines changed: 135 additions & 25 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- ── 005_add_user_language.sql ────────────────────────────────────
2+
-- users 테이블에 언어 설정 컬럼 추가.
3+
-- i18n 언어 코드 (예: 'ko', 'en') 를 서버에 저장해
4+
-- 기기를 바꿔도 동일한 언어 설정이 유지되도록 한다.
5+
6+
ALTER TABLE users ADD COLUMN IF NOT EXISTS language TEXT NOT NULL DEFAULT 'en';

apps/api/src/integration/smoke.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ describe("api integration smoke", () => {
88
{
99
name: "ledger",
1010
displayName: "가계부",
11+
description: "수입과 지출을 기록하고 관리하는 가계부 모듈",
1112
version: "1.0.0",
1213
enabled: true,
1314
dependencies: [],

apps/api/src/loader/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe("api module loader", () => {
5858
{
5959
name: "subscription",
6060
displayName: "구독 관리",
61+
description: "",
6162
version: "1.0.0",
6263
enabled: true,
6364
dependencies: [],

apps/api/src/loader/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface ModuleRoutes {
99
export interface ModuleManifest {
1010
name: string;
1111
displayName: string;
12+
description: string;
1213
version: string;
1314
enabled: boolean;
1415
dependencies: string[];
@@ -39,6 +40,7 @@ export function parseModuleJson(content: string): ModuleManifest {
3940
return {
4041
name: parsed.name ?? "",
4142
displayName: parsed.displayName ?? parsed.name ?? "",
43+
description: parsed.description ?? "",
4244
version: parsed.version ?? "0.0.0",
4345
enabled: parsed.enabled ?? false,
4446
dependencies: parsed.dependencies ?? [],

apps/api/src/routes/core.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export function createCoreRouter(services: AppServices): Router {
8484
const result = registryModules.map((mod) => ({
8585
name: mod.name,
8686
displayName: mod.manifest.displayName,
87+
description: mod.manifest.description,
8788
basePath: mod.basePath,
8889
version: mod.manifest.version,
8990
// user_modules 레코드 없으면 기본 활성
@@ -179,5 +180,67 @@ export function createCoreRouter(services: AppServices): Router {
179180
}
180181
});
181182

183+
// ── 유저 설정 API ──────────────────────────────────────────────
184+
185+
/**
186+
* GET /core/users/me/settings — 현재 유저의 앱 설정 반환
187+
*
188+
* 현재 지원 항목: language (언어 코드, 예: 'ko' | 'en')
189+
*/
190+
router.get('/users/me/settings', auth, async (req, res) => {
191+
try {
192+
const { getDb } = await import('@fieldstack/core');
193+
const database = await getDb();
194+
195+
type Row = { language: string };
196+
const [row] = await database.query<Row>(
197+
'SELECT language FROM users WHERE id = $1',
198+
[req.auth!.userId],
199+
);
200+
201+
res.json({ success: true, data: { language: row?.language ?? 'ko' } });
202+
} catch (err) {
203+
res.status(500).json({ success: false, error: (err as Error).message });
204+
}
205+
});
206+
207+
/**
208+
* PATCH /core/users/me/settings — 현재 유저의 앱 설정 저장
209+
*
210+
* Body: { language?: string }
211+
* 지원 언어 코드: 'ko', 'en'
212+
*/
213+
router.patch('/users/me/settings', auth, async (req, res) => {
214+
const schema = z.object({
215+
language: z.enum(['ko', 'en']).optional(),
216+
});
217+
218+
const parsed = schema.safeParse(req.body);
219+
if (!parsed.success) {
220+
res.status(400).json({ success: false, error: parsed.error.message });
221+
return;
222+
}
223+
224+
const { language } = parsed.data;
225+
if (!language) {
226+
res.status(400).json({ success: false, error: '변경할 설정이 없습니다.' });
227+
return;
228+
}
229+
230+
try {
231+
const { getDb } = await import('@fieldstack/core');
232+
const database = await getDb();
233+
234+
await database.query(
235+
'UPDATE users SET language = $1, updated_at = $2 WHERE id = $3',
236+
[language, new Date().toISOString(), req.auth!.userId],
237+
);
238+
239+
res.json({ success: true, data: { language } });
240+
} catch (err) {
241+
res.status(500).json({ success: false, error: (err as Error).message });
242+
}
243+
});
244+
182245
return router;
183246
}

apps/web/src/components/AppShell.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,17 @@ export function AppShell({
179179
<button
180180
type="button"
181181
className="shell-nav-item"
182-
data-label={mod.displayName || mod.name}
182+
data-label={t(mod.displayName, { defaultValue: mod.name })}
183183
aria-current={isActive && !subRoute ? "page" : undefined}
184184
onClick={() => { window.location.hash = mod.name; closeMobileMenu(); }}
185185
>
186186
<span className="shell-nav-icon" aria-hidden="true">📦</span>
187-
<span className="shell-nav-text">{mod.displayName || mod.name}</span>
187+
<span className="shell-nav-text">{t(mod.displayName, { defaultValue: mod.name })}</span>
188188
</button>
189189

190190
{/* 서브 네비게이션 — 해당 모듈이 활성화됐을 때만 표시 */}
191191
{isActive && subItems && subItems.length > 0 && (
192-
<ul className="shell-subnav-list" aria-label={t('sidebar.subMenu', { name: mod.displayName })}>
192+
<ul className="shell-subnav-list" aria-label={t('sidebar.subMenu', { name: t(mod.displayName, { defaultValue: mod.name }) })}>
193193
{subItems.map((sub) => {
194194
const hash = sub.key ? `${mod.name}/${sub.key}` : mod.name;
195195
const isSubActive = subRoute === sub.key;

apps/web/src/i18n/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ const savedLang = (() => {
1111
i18n
1212
.use(initReactI18next)
1313
.init({
14-
lng: savedLang ?? 'ko',
15-
fallbackLng: 'ko',
14+
lng: savedLang ?? 'en',
15+
fallbackLng: 'en',
1616
resources: {
1717
ko: { common: ko },
1818
en: { common: en },

apps/web/src/main.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { type FormEvent, useEffect, useMemo, useState } from "react";
22
import { createRoot } from "react-dom/client";
33
import { useTranslation } from "react-i18next";
44

5-
import { setSessionExpiredHandler } from "./lib/apiFetch";
5+
import { setSessionExpiredHandler, apiFetch } from "./lib/apiFetch";
66

77
// i18n 초기화 — 다른 import보다 먼저 로드되어야 함
88
import './i18n/index';
9+
import { changeLanguage } from './i18n/index';
910

1011
import "./styles/global.css";
1112
import "./styles/login.css";
@@ -268,6 +269,17 @@ function App() {
268269
try {
269270
if (localStorage.getItem(LS.firstVisitShown) !== "true") setIsFirstVisit(true);
270271
} catch { /* ignore */ }
272+
273+
// 서버에 저장된 언어 설정 로드 (실패해도 무음 처리)
274+
apiFetch("/core/users/me/settings")
275+
.then((r) => r.json())
276+
.then((json: { success: boolean; data?: { language: string } }) => {
277+
if (json.success && json.data?.language) {
278+
void changeLanguage(json.data.language);
279+
}
280+
})
281+
.catch(() => { /* 언어 설정 로드 실패는 무음 처리 */ });
282+
271283
const target = redirectAfterLogin ?? startupRoute;
272284
setRedirectAfterLogin(null);
273285
navigate(target);

apps/web/src/styles/settings.css

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,24 +164,32 @@
164164

165165
.settings-module-info {
166166
display: flex;
167-
align-items: center;
168-
gap: 8px;
167+
flex-direction: column;
168+
gap: 2px;
169169
min-width: 0;
170170
}
171171

172172
.settings-module-name {
173+
display: flex;
174+
align-items: center;
175+
gap: 6px;
173176
font-size: 13px;
174177
font-weight: 600;
175178
color: var(--text);
176-
overflow: hidden;
177-
text-overflow: ellipsis;
178-
white-space: nowrap;
179179
}
180180

181181
.settings-module-version {
182182
font-size: 11px;
183183
color: var(--text-faint);
184-
flex-shrink: 0;
184+
font-weight: 400;
185+
}
186+
187+
.settings-module-desc {
188+
font-size: 12px;
189+
color: var(--text-muted);
190+
overflow: hidden;
191+
text-overflow: ellipsis;
192+
white-space: nowrap;
185193
}
186194

187195
.settings-dialog-footer {

apps/web/src/views/HomeView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const MOCK_RECENT_ACTIVITY = [
2121

2222
interface InstalledModule {
2323
name: string;
24+
displayName: string;
25+
description: string;
2426
basePath: string;
2527
enabled: boolean;
2628
}
@@ -117,8 +119,8 @@ export function HomeView({
117119
onClick={() => { window.location.hash = mod.name; }}
118120
>
119121
<p className="module-card-icon">{MODULE_ICONS[mod.name] ?? "🧩"}</p>
120-
<p className="module-card-name">{mod.name}</p>
121-
<p className="module-card-desc">{t('home.openModule')}</p>
122+
<p className="module-card-name">{t(mod.displayName, { defaultValue: mod.name })}</p>
123+
<p className="module-card-desc">{t(mod.description, { defaultValue: mod.description })}</p>
122124
</button>
123125
))}
124126
</div>

0 commit comments

Comments
 (0)