From efbf970a18089a6d0824ae688c6cce09a9374399 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 11:41:48 +0900 Subject: [PATCH 01/21] =?UTF-8?q?=EA=B3=B5=ED=86=B5=20i18n=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/.gitkeep | 1 + common/core/auth/.gitkeep | 1 + common/core/i18n/index.ts | 11 +++ common/core/i18n/loader.ts | 176 +++++++++++++++++++++++++++++++++++ common/core/i18n/types.ts | 20 ++++ common/core/query/.gitkeep | 1 + common/core/session/.gitkeep | 1 + common/core/utils/.gitkeep | 1 + common/locales/en.toml | 77 +++++++++++++++ common/locales/ko.toml | 77 +++++++++++++++ common/locales/template.toml | 77 +++++++++++++++ common/shell/.gitkeep | 1 + common/theme/.gitkeep | 1 + common/ui/.gitkeep | 1 + 14 files changed, 446 insertions(+) create mode 100644 common/.gitkeep create mode 100644 common/core/auth/.gitkeep create mode 100644 common/core/i18n/index.ts create mode 100644 common/core/i18n/loader.ts create mode 100644 common/core/i18n/types.ts create mode 100644 common/core/query/.gitkeep create mode 100644 common/core/session/.gitkeep create mode 100644 common/core/utils/.gitkeep create mode 100644 common/locales/en.toml create mode 100644 common/locales/ko.toml create mode 100644 common/locales/template.toml create mode 100644 common/shell/.gitkeep create mode 100644 common/theme/.gitkeep create mode 100644 common/ui/.gitkeep diff --git a/common/.gitkeep b/common/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/core/auth/.gitkeep b/common/core/auth/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/core/auth/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/core/i18n/index.ts b/common/core/i18n/index.ts new file mode 100644 index 00000000..77517663 --- /dev/null +++ b/common/core/i18n/index.ts @@ -0,0 +1,11 @@ +export { createTomlTranslator } from "./loader"; +export { + DEFAULT_LOCALE, + LOCALE_STORAGE_KEY, + SUPPORTED_LOCALES, + type Locale, + type TomlObject, + type TomlValue, + type TranslatorInput, + type TranslatorOptions, +} from "./types"; diff --git a/common/core/i18n/loader.ts b/common/core/i18n/loader.ts new file mode 100644 index 00000000..71d1decd --- /dev/null +++ b/common/core/i18n/loader.ts @@ -0,0 +1,176 @@ +import { + DEFAULT_LOCALE, + LOCALE_STORAGE_KEY, + SUPPORTED_LOCALES, + type Locale, + type TomlObject, + type TomlValue, + type TranslatorInput, + type TranslatorOptions, +} from "./types"; + +function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject { + const result: TomlObject = { ...base }; + + for (const [key, value] of Object.entries(override)) { + const currentValue = result[key]; + if ( + typeof currentValue === "object" && + currentValue !== null && + typeof value === "object" && + value !== null + ) { + result[key] = mergeTomlObjects(currentValue as TomlObject, value); + continue; + } + result[key] = value; + } + + return result; +} + +function isSupportedLocale(value: string): value is Locale { + return (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +function parseToml(raw: string): TomlObject { + const lines = raw.split(/\r?\n/); + const root: TomlObject = {}; + let currentPath: string[] = []; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + if (line.startsWith("[") && line.endsWith("]")) { + const sectionName = line.slice(1, -1).trim(); + currentPath = sectionName + ? sectionName + .split(".") + .map((part) => part.trim()) + .filter(Boolean) + : []; + continue; + } + + const eqIndex = line.indexOf("="); + if (eqIndex === -1) { + continue; + } + + const key = line.slice(0, eqIndex).trim(); + const valueRaw = line.slice(eqIndex + 1).trim(); + if (!key) { + continue; + } + + let value = valueRaw; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + let cursor: TomlObject = root; + for (const section of currentPath) { + if (!cursor[section] || typeof cursor[section] === "string") { + cursor[section] = {}; + } + cursor = cursor[section] as TomlObject; + } + + cursor[key] = value; + } + + return root; +} + +function getValue(target: TomlObject, key: string): string | undefined { + const parts = key.split("."); + let cursor: TomlValue = target; + for (const part of parts) { + if (typeof cursor !== "object" || cursor === null) { + return undefined; + } + cursor = (cursor as TomlObject)[part]; + if (cursor === undefined) { + return undefined; + } + } + return typeof cursor === "string" ? cursor : undefined; +} + +function detectLocale(): Locale { + if (typeof window === "undefined") { + return DEFAULT_LOCALE; + } + + const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (stored && isSupportedLocale(stored)) { + return stored; + } + + const pathLocale = window.location.pathname.split("/")[1]; + if (pathLocale && isSupportedLocale(pathLocale)) { + return pathLocale; + } + + const browserLang = window.navigator.language.toLowerCase(); + if (browserLang.startsWith("ko")) { + return "ko"; + } + + return DEFAULT_LOCALE; +} + +function formatTemplate( + template: string, + vars?: Record, + options?: TranslatorOptions, +): string { + const normalizedTemplate = options?.normalizeEscapedNewlines + ? template.replace(/\\n/g, "\n") + : template; + + if (!vars) { + return normalizedTemplate; + } + + return normalizedTemplate.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { + const value = vars[key]; + if (value === undefined || value === null) { + return match; + } + return String(value); + }); +} + +export function createTomlTranslator( + input: TranslatorInput, + options?: TranslatorOptions, +) { + const translations: Record = { + ko: input.ko + .map((raw) => parseToml(raw)) + .reduce((merged, current) => mergeTomlObjects(merged, current), {}), + en: input.en + .map((raw) => parseToml(raw)) + .reduce((merged, current) => mergeTomlObjects(merged, current), {}), + }; + + return function t( + key: string, + fallback?: string, + vars?: Record, + ): string { + const locale = detectLocale(); + const value = getValue(translations[locale], key); + if (value && value.length > 0) { + return formatTemplate(value, vars, options); + } + return formatTemplate(fallback ?? key, vars, options); + }; +} diff --git a/common/core/i18n/types.ts b/common/core/i18n/types.ts new file mode 100644 index 00000000..b8db5ec3 --- /dev/null +++ b/common/core/i18n/types.ts @@ -0,0 +1,20 @@ +export const LOCALE_STORAGE_KEY = "locale"; +export const DEFAULT_LOCALE = "ko"; +export const SUPPORTED_LOCALES = ["ko", "en"] as const; + +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +export type TomlValue = string | TomlObject; + +export interface TomlObject { + [key: string]: TomlValue; +} + +export interface TranslatorOptions { + normalizeEscapedNewlines?: boolean; +} + +export interface TranslatorInput { + en: string[]; + ko: string[]; +} diff --git a/common/core/query/.gitkeep b/common/core/query/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/core/query/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/core/session/.gitkeep b/common/core/session/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/core/session/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/core/utils/.gitkeep b/common/core/utils/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/core/utils/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/locales/en.toml b/common/locales/en.toml new file mode 100644 index 00000000..73d530b6 --- /dev/null +++ b/common/locales/en.toml @@ -0,0 +1,77 @@ +[msg.common] +error = "Error" +loading = "Loading..." +no_description = "No Description." +parsing = "Parsing data..." +requesting = "Requesting..." +saving = "Saving..." +unknown_error = "unknown error" + +[ui.common] +add = "Add" +all = "All" +admin_only = "Admin Only" +assign = "Assign" +back = "Back" +back_to_login = "Back to login" +cancel = "Cancel" +change_file = "Change File" +clear_search = "Clear Search" +close = "Close" +collapse = "Collapse" +confirm = "Confirm" +copy = "Copy" +create = "Create" +delete = "Delete" +details = "Details" +disabled = "Disabled" +edit = "Edit" +enabled = "Enabled" +export = "Export" +fail = "Fail" +go_home = "Go Home" +view = "View" +hyphen = "-" +manage = "Manage" +na = "N/A" +never = "Never" +next = "Next" +none = "None" +page_of = "Page {{page}} of {{total}}" +prev = "Prev" +previous = "Previous" +qr = "QR" +reset = "Reset" +read_only = "Read Only" +refresh = "Refresh" +remove = "Remove" +resend = "Resend" +retry = "Retry" +save = "Save" +search = "Search" +select = "Select" +select_file = "Select File" +select_placeholder = "Select Placeholder" +show_more = "Show More" +language = "Language" +language_ko = "Korean" +language_en = "English" +success = "Success" +theme_dark = "Dark" +theme_light = "Light" +theme_toggle = "Theme Toggle" +unknown = "Unknown" + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "Active" +blocked = "Blocked" +failure = "Failure" +inactive = "Inactive" +ok = "Ok" +pending = "Pending" +success = "Success" diff --git a/common/locales/ko.toml b/common/locales/ko.toml new file mode 100644 index 00000000..f5cf7c74 --- /dev/null +++ b/common/locales/ko.toml @@ -0,0 +1,77 @@ +[msg.common] +error = "오류가 발생했습니다." +loading = "로딩 중..." +no_description = "설명이 없습니다." +parsing = "데이터 파싱 중..." +requesting = "요청 중..." +saving = "저장 중..." +unknown_error = "알 수 없는 오류" + +[ui.common] +add = "추가" +all = "전체" +admin_only = "관리자 전용" +assign = "할당" +back = "돌아가기" +back_to_login = "로그인으로 돌아가기" +cancel = "취소" +change_file = "파일 변경" +clear_search = "검색 초기화" +close = "닫기" +collapse = "접기" +confirm = "확인" +copy = "복사" +create = "생성" +delete = "삭제" +details = "상세정보" +disabled = "사용 안 함" +edit = "편집" +enabled = "사용" +export = "내보내기" +fail = "실패" +go_home = "홈으로" +view = "보기" +hyphen = "-" +manage = "관리" +na = "N/A" +never = "Never" +next = "다음" +none = "없음" +page_of = "Page {{page}} of {{total}}" +prev = "이전" +previous = "이전" +qr = "QR" +reset = "초기화" +read_only = "읽기 전용" +refresh = "새로고침" +remove = "제외" +resend = "재발송" +retry = "다시 시도" +save = "저장" +search = "검색" +select = "선택" +select_file = "파일 선택" +select_placeholder = "선택하세요" +show_more = "+ 더보기" +language = "언어" +language_ko = "한국어" +language_en = "English" +success = "성공" +theme_dark = "Dark" +theme_light = "Light" +theme_toggle = "테마 전환" +unknown = "Unknown" + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "활성" +blocked = "차단됨" +failure = "실패" +inactive = "비활성" +ok = "정상" +pending = "준비 중" +success = "성공" diff --git a/common/locales/template.toml b/common/locales/template.toml new file mode 100644 index 00000000..1cb9e149 --- /dev/null +++ b/common/locales/template.toml @@ -0,0 +1,77 @@ +[msg.common] +error = "" +loading = "" +no_description = "" +parsing = "" +requesting = "" +saving = "" +unknown_error = "" + +[ui.common] +add = "" +all = "" +admin_only = "" +assign = "" +back = "" +back_to_login = "" +cancel = "" +change_file = "" +clear_search = "" +close = "" +collapse = "" +confirm = "" +copy = "" +create = "" +delete = "" +details = "" +disabled = "" +edit = "" +enabled = "" +export = "" +fail = "" +go_home = "" +view = "" +hyphen = "" +manage = "" +na = "" +never = "" +next = "" +none = "" +page_of = "" +prev = "" +previous = "" +qr = "" +reset = "" +read_only = "" +refresh = "" +remove = "" +resend = "" +retry = "" +save = "" +search = "" +select = "" +select_file = "" +select_placeholder = "" +show_more = "" +language = "" +language_ko = "" +language_en = "" +success = "" +theme_dark = "" +theme_light = "" +theme_toggle = "" +unknown = "" + +[ui.common.badge] +admin_only = "" +command_only = "" +system = "" + +[ui.common.status] +active = "" +blocked = "" +failure = "" +inactive = "" +ok = "" +pending = "" +success = "" diff --git a/common/shell/.gitkeep b/common/shell/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/shell/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/theme/.gitkeep b/common/theme/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/theme/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/ui/.gitkeep b/common/ui/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/ui/.gitkeep @@ -0,0 +1 @@ + From b8a25135fc23bd2bc82b68b59903a000e5e1bb5d Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 11:42:24 +0900 Subject: [PATCH 02/21] =?UTF-8?q?=EA=B0=81=20=ED=94=84=EB=9F=B0=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20i18n=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20locale=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/lib/i18n.ts | 155 +++------------------------ adminfront/src/locales/en.toml | 66 ------------ adminfront/src/locales/ko.toml | 66 ------------ adminfront/src/locales/template.toml | 66 ------------ devfront/src/lib/i18n.ts | 151 ++------------------------ devfront/src/locales/en.toml | 78 -------------- devfront/src/locales/ko.toml | 78 -------------- devfront/src/locales/template.toml | 78 -------------- orgfront/src/lib/i18n.ts | 150 ++------------------------ orgfront/src/locales/en.toml | 76 ------------- orgfront/src/locales/ko.toml | 76 ------------- orgfront/src/locales/template.toml | 76 ------------- 12 files changed, 32 insertions(+), 1084 deletions(-) diff --git a/adminfront/src/lib/i18n.ts b/adminfront/src/lib/i18n.ts index 53540cb7..d96190f7 100644 --- a/adminfront/src/lib/i18n.ts +++ b/adminfront/src/lib/i18n.ts @@ -1,148 +1,21 @@ -const LOCALE_STORAGE_KEY = "locale"; -const DEFAULT_LOCALE = "ko"; -const SUPPORTED_LOCALES = ["ko", "en"] as const; - -type Locale = (typeof SUPPORTED_LOCALES)[number]; - -type TomlValue = string | TomlObject; - -interface TomlObject { - [key: string]: TomlValue; -} - -function isSupportedLocale(value: string): value is Locale { - return (SUPPORTED_LOCALES as readonly string[]).includes(value); -} - -function parseToml(raw: string): TomlObject { - const lines = raw.split(/\r?\n/); - const root: TomlObject = {}; - let currentPath: string[] = []; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - - if (line.startsWith("[") && line.endsWith("]")) { - const sectionName = line.slice(1, -1).trim(); - currentPath = sectionName - ? sectionName - .split(".") - .map((part) => part.trim()) - .filter(Boolean) - : []; - continue; - } - - const eqIndex = line.indexOf("="); - if (eqIndex === -1) { - continue; - } - - const key = line.slice(0, eqIndex).trim(); - const valueRaw = line.slice(eqIndex + 1).trim(); - if (!key) { - continue; - } - - let value = valueRaw; - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - let cursor: TomlObject = root; - for (const section of currentPath) { - if (!cursor[section] || typeof cursor[section] === "string") { - cursor[section] = {}; - } - cursor = cursor[section] as TomlObject; - } - - cursor[key] = value; - } - - return root; -} - -function getValue(target: TomlObject, key: string): string | undefined { - const parts = key.split("."); - let cursor: TomlValue = target; - for (const part of parts) { - if (typeof cursor !== "object" || cursor === null) { - return undefined; - } - cursor = (cursor as TomlObject)[part]; - if (cursor === undefined) { - return undefined; - } - } - return typeof cursor === "string" ? cursor : undefined; -} - -function detectLocale(): Locale { - if (typeof window === "undefined") { - return DEFAULT_LOCALE; - } - - const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY); - if (stored && isSupportedLocale(stored)) { - return stored; - } - - const pathLocale = window.location.pathname.split("/")[1]; - if (pathLocale && isSupportedLocale(pathLocale)) { - return pathLocale; - } - - const browserLang = window.navigator.language.toLowerCase(); - if (browserLang.startsWith("ko")) { - return "ko"; - } - - return DEFAULT_LOCALE; -} +import { createTomlTranslator } from "../../../common/core/i18n"; +// eslint-disable-next-line import/no-unresolved +import commonEnRaw from "../../../common/locales/en.toml?raw"; +// eslint-disable-next-line import/no-unresolved +import commonKoRaw from "../../../common/locales/ko.toml?raw"; // eslint-disable-next-line import/no-unresolved import enRaw from "../locales/en.toml?raw"; // Vite ?raw import는 런타임 상수로 번들됩니다. // eslint-disable-next-line import/no-unresolved import koRaw from "../locales/ko.toml?raw"; -const translations: Record = { - ko: parseToml(koRaw), - en: parseToml(enRaw), -}; - -function formatTemplate( - template: string, - vars?: Record, -): string { - if (!vars) { - return template; - } - return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { - const value = vars[key]; - if (value === undefined || value === null) { - return match; - } - return String(value); - }); -} - -export function t( - key: string, - fallback?: string, - vars?: Record, -): string { - const locale = detectLocale(); - const value = getValue(translations[locale], key); - if (value && value.length > 0) { - return formatTemplate(value, vars); - } - return formatTemplate(fallback ?? key, vars); -} +export const t = createTomlTranslator( + { + ko: [commonKoRaw, koRaw], + en: [commonEnRaw, enRaw], + }, + { + normalizeEscapedNewlines: true, + }, +); diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 22645e1d..7ef622d7 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -327,15 +327,6 @@ no_custom = "No custom fields defined for this tenant." [msg.admin.users.list.registry] count = "Count" -[msg.common] -error = "Error" -loading = "Loading..." -no_description = "No Description." -parsing = "Parsing data..." -requesting = "Requesting..." -saving = "Saving..." -unknown_error = "unknown error" - [msg.dev] logout_confirm = "Are you sure you want to log out?" @@ -1281,63 +1272,6 @@ name = "Name" role = "Role" -[ui.common] -add = "Add" -all = "All" -admin_only = "Admin Only" -assign = "Assign" -back = "Back" -cancel = "Cancel" -change_file = "Change File" -clear_search = "Clear Search" -close = "Close" -collapse = "Collapse" -confirm = "Confirm" -copy = "Copy" -create = "Create" -delete = "Delete" -details = "Details" -edit = "Edit" -export = "Export" -fail = "Fail" -go_home = "Go Home" -view = "View" -hyphen = "-" -manage = "Manage" -na = "N/A" -never = "Never" -next = "Next" -none = "None" -page_of = "Page {{page}} of {{total}}" -prev = "Prev" -previous = "Previous" -qr = "QR" -reset = "Reset" -read_only = "Read Only" -refresh = "Refresh" -remove = "Remove" -resend = "Resend" -retry = "Retry" -save = "Save" -search = "Search" -select = "Select" -select_file = "Select File" -select_placeholder = "Select Placeholder" -show_more = "Show More" -language = "Language" -language_ko = "Korean" -language_en = "English" -success = "Success" -theme_dark = "Dark" -theme_light = "Light" -theme_toggle = "Theme Toggle" -unknown = "Unknown" - -[ui.common.badge] -admin_only = "Admin only" -command_only = "Command only" -system = "System" - [ui.common.role] admin = "Admin" rp_admin = "RP Admin" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index b3c9ab3c..dcbee70c 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -329,15 +329,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다." [msg.admin.users.list.registry] count = "총 {{count}}명의 사용자가 등록되어 있습니다." -[msg.common] -error = "오류가 발생했습니다." -loading = "로딩 중..." -no_description = "설명이 없습니다." -parsing = "데이터 파싱 중..." -requesting = "요청 중..." -saving = "저장 중..." -unknown_error = "알 수 없는 오류" - [msg.dev] logout_confirm = "로그아웃 하시겠습니까?" @@ -1283,63 +1274,6 @@ name = "이름" role = "역할" -[ui.common] -add = "추가" -all = "전체" -admin_only = "관리자 전용" -assign = "할당" -back = "돌아가기" -cancel = "취소" -change_file = "파일 변경" -clear_search = "검색 초기화" -close = "닫기" -collapse = "접기" -confirm = "확인" -copy = "복사" -create = "생성" -delete = "삭제" -details = "상세정보" -edit = "편집" -export = "내보내기" -fail = "실패" -go_home = "홈으로" -view = "보기" -hyphen = "-" -manage = "관리" -na = "N/A" -never = "Never" -next = "다음" -none = "없음" -page_of = "Page {{page}} of {{total}}" -prev = "이전" -previous = "이전" -qr = "QR" -reset = "초기화" -read_only = "읽기 전용" -refresh = "새로고침" -remove = "제외" -resend = "재발송" -retry = "다시 시도" -save = "저장" -search = "검색" -select = "선택" -select_file = "파일 선택" -select_placeholder = "선택하세요" -show_more = "+ 더보기" -language = "언어" -language_ko = "한국어" -language_en = "English" -success = "성공" -theme_dark = "Dark" -theme_light = "Light" -theme_toggle = "테마 전환" -unknown = "Unknown" - -[ui.common.badge] -admin_only = "Admin only" -command_only = "Command only" -system = "System" - [ui.common.role] admin = "Admin" rp_admin = "RP Admin" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index e85b29ba..55394070 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -337,15 +337,6 @@ no_custom = "" [msg.admin.users.list.registry] count = "" -[msg.common] -error = "" -loading = "" -no_description = "" -parsing = "" -requesting = "" -saving = "" -unknown_error = "" - [msg.dev] logout_confirm = "" @@ -1261,63 +1252,6 @@ name = "" role = "" -[ui.common] -add = "" -all = "" -admin_only = "" -assign = "" -back = "" -cancel = "" -change_file = "" -clear_search = "" -close = "" -collapse = "" -confirm = "" -copy = "" -create = "" -delete = "" -details = "" -edit = "" -export = "" -fail = "" -go_home = "" -view = "" -hyphen = "" -manage = "" -na = "" -never = "" -next = "" -none = "" -page_of = "" -prev = "" -previous = "" -qr = "" -reset = "" -read_only = "" -refresh = "" -remove = "" -resend = "" -retry = "" -save = "" -search = "" -select = "" -select_file = "" -select_placeholder = "" -show_more = "" -language = "" -language_ko = "" -language_en = "" -success = "" -theme_dark = "" -theme_light = "" -theme_toggle = "" -unknown = "" - -[ui.common.badge] -admin_only = "" -command_only = "" -system = "" - [ui.common.role] admin = "" rp_admin = "" diff --git a/devfront/src/lib/i18n.ts b/devfront/src/lib/i18n.ts index ee5265e1..91b55a98 100644 --- a/devfront/src/lib/i18n.ts +++ b/devfront/src/lib/i18n.ts @@ -1,149 +1,16 @@ -const LOCALE_STORAGE_KEY = "locale"; -const DEFAULT_LOCALE = "ko"; -const SUPPORTED_LOCALES = ["ko", "en"] as const; - -type Locale = (typeof SUPPORTED_LOCALES)[number]; - -type TomlValue = string | TomlObject; - -interface TomlObject { - [key: string]: TomlValue; -} - -function isSupportedLocale(value: string): value is Locale { - return (SUPPORTED_LOCALES as readonly string[]).includes(value); -} - -function parseToml(raw: string): TomlObject { - const lines = raw.split(/\r?\n/); - const root: TomlObject = {}; - let currentPath: string[] = []; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - - if (line.startsWith("[") && line.endsWith("]")) { - const sectionName = line.slice(1, -1).trim(); - currentPath = sectionName - ? sectionName - .split(".") - .map((part) => part.trim()) - .filter(Boolean) - : []; - continue; - } - - const eqIndex = line.indexOf("="); - if (eqIndex === -1) { - continue; - } - - const key = line.slice(0, eqIndex).trim(); - const valueRaw = line.slice(eqIndex + 1).trim(); - if (!key) { - continue; - } - - let value = valueRaw; - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - let cursor: TomlObject = root; - for (const section of currentPath) { - if (!cursor[section] || typeof cursor[section] === "string") { - cursor[section] = {}; - } - cursor = cursor[section] as TomlObject; - } - - cursor[key] = value; - } - - return root; -} - -function getValue(target: TomlObject, key: string): string | undefined { - const parts = key.split("."); - let cursor: TomlValue = target; - for (const part of parts) { - if (typeof cursor !== "object" || cursor === null) { - return undefined; - } - cursor = (cursor as TomlObject)[part]; - if (cursor === undefined) { - return undefined; - } - } - return typeof cursor === "string" ? cursor : undefined; -} - -function detectLocale(): Locale { - if (typeof window === "undefined") { - return DEFAULT_LOCALE; - } - - const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY); - if (stored && isSupportedLocale(stored)) { - return stored; - } - - const pathLocale = window.location.pathname.split("/")[1]; - if (pathLocale && isSupportedLocale(pathLocale)) { - return pathLocale; - } - - const browserLang = window.navigator.language.toLowerCase(); - if (browserLang.startsWith("ko")) { - return "ko"; - } - - return DEFAULT_LOCALE; -} +import { createTomlTranslator } from "../../../common/core/i18n"; +// eslint-disable-next-line import/no-unresolved +import commonEnRaw from "../../../common/locales/en.toml?raw"; +// eslint-disable-next-line import/no-unresolved +import commonKoRaw from "../../../common/locales/ko.toml?raw"; // eslint-disable-next-line import/no-unresolved import enRaw from "../locales/en.toml?raw"; // Vite ?raw import는 런타임 상수로 번들됩니다. // eslint-disable-next-line import/no-unresolved import koRaw from "../locales/ko.toml?raw"; -const translations: Record = { - ko: parseToml(koRaw), - en: parseToml(enRaw), -}; - -function formatTemplate( - template: string, - vars?: Record, -): string { - const normalizedTemplate = template.replace(/\\n/g, "\n"); - if (!vars) { - return normalizedTemplate; - } - return normalizedTemplate.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { - const value = vars[key]; - if (value === undefined || value === null) { - return match; - } - return String(value); - }); -} - -export function t( - key: string, - fallback?: string, - vars?: Record, -): string { - const locale = detectLocale(); - const value = getValue(translations[locale], key); - if (value && value.length > 0) { - return formatTemplate(value, vars); - } - return formatTemplate(fallback ?? key, vars); -} +export const t = createTomlTranslator({ + ko: [commonKoRaw, koRaw], + en: [commonEnRaw, enRaw], +}); diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index ed6ce6a2..f81818bc 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -300,15 +300,6 @@ no_custom = "No custom fields defined for this tenant." [msg.admin.users.list.registry] count = "Count" -[msg.common] -error = "Error" -loading = "Loading..." -no_description = "No Description." -parsing = "Parsing data..." -requesting = "Requesting..." -saving = "Saving..." -unknown_error = "unknown error" - [msg.dev] logout_confirm = "Are you sure you want to log out?" @@ -1244,75 +1235,6 @@ name = "Name" role = "Role" -[ui.common] -add = "Add" -all = "All" -admin_only = "Admin Only" -assign = "Assign" -back = "Back" -back_to_login = "Back to login" -cancel = "Cancel" -change_file = "Change File" -clear_search = "Clear Search" -close = "Close" -collapse = "Collapse" -confirm = "Confirm" -copy = "Copy" -create = "Create" -delete = "Delete" -details = "Details" -disabled = "Disabled" -edit = "Edit" -enabled = "Enabled" -export = "Export" -fail = "Fail" -go_home = "Go Home" -view = "View" -hyphen = "-" -manage = "Manage" -na = "N/A" -never = "Never" -next = "Next" -none = "None" -page_of = "Page {{page}} of {{total}}" -prev = "Prev" -previous = "Previous" -qr = "QR" -reset = "Reset" -read_only = "Read Only" -refresh = "Refresh" -remove = "Remove" -resend = "Resend" -retry = "Retry" -save = "Save" -search = "Search" -select = "Select" -select_file = "Select File" -select_placeholder = "Select Placeholder" -show_more = "Show More" -language = "Language" -language_ko = "Korean" -language_en = "English" -success = "Success" -theme_dark = "Dark" -theme_light = "Light" -theme_toggle = "Theme Toggle" -unknown = "Unknown" - -[ui.common.badge] -admin_only = "Admin only" -command_only = "Command only" -system = "System" - -[ui.common.status] -active = "Active" -blocked = "Blocked" -failure = "Failure" -inactive = "Inactive" -ok = "Ok" -pending = "Pending" -success = "Success" - [test] key = "Test" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index c23efa55..c00aca4d 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -300,15 +300,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다." [msg.admin.users.list.registry] count = "총 {{count}}명의 사용자가 등록되어 있습니다." -[msg.common] -error = "오류가 발생했습니다." -loading = "로딩 중..." -no_description = "설명이 없습니다." -parsing = "데이터 파싱 중..." -requesting = "요청 중..." -saving = "저장 중..." -unknown_error = "알 수 없는 오류" - [msg.dev] logout_confirm = "로그아웃 하시겠습니까?" @@ -1244,75 +1235,6 @@ name = "이름" role = "역할" -[ui.common] -add = "추가" -all = "전체" -admin_only = "관리자 전용" -assign = "할당" -back = "돌아가기" -back_to_login = "로그인으로 돌아가기" -cancel = "취소" -change_file = "파일 변경" -clear_search = "검색 초기화" -close = "닫기" -collapse = "접기" -confirm = "확인" -copy = "복사" -create = "생성" -delete = "삭제" -details = "상세정보" -disabled = "사용 안 함" -edit = "편집" -enabled = "사용" -export = "내보내기" -fail = "실패" -go_home = "홈으로" -view = "보기" -hyphen = "-" -manage = "관리" -na = "N/A" -never = "Never" -next = "다음" -none = "없음" -page_of = "Page {{page}} of {{total}}" -prev = "이전" -previous = "이전" -qr = "QR" -reset = "초기화" -read_only = "읽기 전용" -refresh = "새로고침" -remove = "제외" -resend = "재발송" -retry = "다시 시도" -save = "저장" -search = "검색" -select = "선택" -select_file = "파일 선택" -select_placeholder = "선택하세요" -show_more = "+ 더보기" -language = "언어" -language_ko = "한국어" -language_en = "English" -success = "성공" -theme_dark = "Dark" -theme_light = "Light" -theme_toggle = "테마 전환" -unknown = "Unknown" - -[ui.common.badge] -admin_only = "Admin only" -command_only = "Command only" -system = "System" - -[ui.common.status] -active = "활성" -blocked = "차단됨" -failure = "실패" -inactive = "비활성" -ok = "정상" -pending = "준비 중" -success = "성공" - [test] key = "테스트" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 32e7cae4..7cacce98 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -314,15 +314,6 @@ no_custom = "" [msg.admin.users.list.registry] count = "" -[msg.common] -error = "" -loading = "" -no_description = "" -parsing = "" -requesting = "" -saving = "" -unknown_error = "" - [msg.dev] logout_confirm = "" @@ -1297,75 +1288,6 @@ name = "" role = "" -[ui.common] -add = "" -all = "" -admin_only = "" -assign = "" -back = "" -back_to_login = "" -cancel = "" -change_file = "" -clear_search = "" -close = "" -collapse = "" -confirm = "" -copy = "" -create = "" -delete = "" -details = "" -disabled = "" -edit = "" -enabled = "" -export = "" -fail = "" -go_home = "" -view = "" -hyphen = "" -manage = "" -na = "" -never = "" -next = "" -none = "" -page_of = "" -prev = "" -previous = "" -qr = "" -reset = "" -read_only = "" -refresh = "" -remove = "" -resend = "" -retry = "" -save = "" -search = "" -select = "" -select_file = "" -select_placeholder = "" -show_more = "" -language = "" -language_ko = "" -language_en = "" -success = "" -theme_dark = "" -theme_light = "" -theme_toggle = "" -unknown = "" - -[ui.common.badge] -admin_only = "" -command_only = "" -system = "" - -[ui.common.status] -active = "" -blocked = "" -failure = "" -inactive = "" -ok = "" -pending = "" -success = "" - [test] key = "" diff --git a/orgfront/src/lib/i18n.ts b/orgfront/src/lib/i18n.ts index 53540cb7..91b55a98 100644 --- a/orgfront/src/lib/i18n.ts +++ b/orgfront/src/lib/i18n.ts @@ -1,148 +1,16 @@ -const LOCALE_STORAGE_KEY = "locale"; -const DEFAULT_LOCALE = "ko"; -const SUPPORTED_LOCALES = ["ko", "en"] as const; - -type Locale = (typeof SUPPORTED_LOCALES)[number]; - -type TomlValue = string | TomlObject; - -interface TomlObject { - [key: string]: TomlValue; -} - -function isSupportedLocale(value: string): value is Locale { - return (SUPPORTED_LOCALES as readonly string[]).includes(value); -} - -function parseToml(raw: string): TomlObject { - const lines = raw.split(/\r?\n/); - const root: TomlObject = {}; - let currentPath: string[] = []; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - - if (line.startsWith("[") && line.endsWith("]")) { - const sectionName = line.slice(1, -1).trim(); - currentPath = sectionName - ? sectionName - .split(".") - .map((part) => part.trim()) - .filter(Boolean) - : []; - continue; - } - - const eqIndex = line.indexOf("="); - if (eqIndex === -1) { - continue; - } - - const key = line.slice(0, eqIndex).trim(); - const valueRaw = line.slice(eqIndex + 1).trim(); - if (!key) { - continue; - } - - let value = valueRaw; - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - let cursor: TomlObject = root; - for (const section of currentPath) { - if (!cursor[section] || typeof cursor[section] === "string") { - cursor[section] = {}; - } - cursor = cursor[section] as TomlObject; - } - - cursor[key] = value; - } - - return root; -} - -function getValue(target: TomlObject, key: string): string | undefined { - const parts = key.split("."); - let cursor: TomlValue = target; - for (const part of parts) { - if (typeof cursor !== "object" || cursor === null) { - return undefined; - } - cursor = (cursor as TomlObject)[part]; - if (cursor === undefined) { - return undefined; - } - } - return typeof cursor === "string" ? cursor : undefined; -} - -function detectLocale(): Locale { - if (typeof window === "undefined") { - return DEFAULT_LOCALE; - } - - const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY); - if (stored && isSupportedLocale(stored)) { - return stored; - } - - const pathLocale = window.location.pathname.split("/")[1]; - if (pathLocale && isSupportedLocale(pathLocale)) { - return pathLocale; - } - - const browserLang = window.navigator.language.toLowerCase(); - if (browserLang.startsWith("ko")) { - return "ko"; - } - - return DEFAULT_LOCALE; -} +import { createTomlTranslator } from "../../../common/core/i18n"; +// eslint-disable-next-line import/no-unresolved +import commonEnRaw from "../../../common/locales/en.toml?raw"; +// eslint-disable-next-line import/no-unresolved +import commonKoRaw from "../../../common/locales/ko.toml?raw"; // eslint-disable-next-line import/no-unresolved import enRaw from "../locales/en.toml?raw"; // Vite ?raw import는 런타임 상수로 번들됩니다. // eslint-disable-next-line import/no-unresolved import koRaw from "../locales/ko.toml?raw"; -const translations: Record = { - ko: parseToml(koRaw), - en: parseToml(enRaw), -}; - -function formatTemplate( - template: string, - vars?: Record, -): string { - if (!vars) { - return template; - } - return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { - const value = vars[key]; - if (value === undefined || value === null) { - return match; - } - return String(value); - }); -} - -export function t( - key: string, - fallback?: string, - vars?: Record, -): string { - const locale = detectLocale(); - const value = getValue(translations[locale], key); - if (value && value.length > 0) { - return formatTemplate(value, vars); - } - return formatTemplate(fallback ?? key, vars); -} +export const t = createTomlTranslator({ + ko: [commonKoRaw, koRaw], + en: [commonEnRaw, enRaw], +}); diff --git a/orgfront/src/locales/en.toml b/orgfront/src/locales/en.toml index 1794ae04..0749b5af 100644 --- a/orgfront/src/locales/en.toml +++ b/orgfront/src/locales/en.toml @@ -302,15 +302,6 @@ no_custom = "No custom fields defined for this tenant." [msg.admin.users.list.registry] count = "Count" -[msg.common] -error = "Error" -loading = "Loading..." -no_description = "No Description." -parsing = "Parsing data..." -requesting = "Requesting..." -saving = "Saving..." -unknown_error = "unknown error" - [msg.dev] logout_confirm = "Are you sure you want to log out?" @@ -1176,73 +1167,6 @@ name = "Name" role = "Role" -[ui.common] -add = "Add" -all = "All" -admin_only = "Admin Only" -assign = "Assign" -back = "Back" -back_to_login = "Back to login" -cancel = "Cancel" -change_file = "Change File" -clear_search = "Clear Search" -close = "Close" -collapse = "Collapse" -confirm = "Confirm" -copy = "Copy" -create = "Create" -delete = "Delete" -details = "Details" -edit = "Edit" -export = "Export" -fail = "Fail" -go_home = "Go Home" -view = "View" -hyphen = "-" -manage = "Manage" -na = "N/A" -never = "Never" -next = "Next" -none = "None" -page_of = "Page {{page}} of {{total}}" -prev = "Prev" -previous = "Previous" -qr = "QR" -reset = "Reset" -read_only = "Read Only" -refresh = "Refresh" -remove = "Remove" -resend = "Resend" -retry = "Retry" -save = "Save" -search = "Search" -select = "Select" -select_file = "Select File" -select_placeholder = "Select Placeholder" -show_more = "Show More" -language = "Language" -language_ko = "Korean" -language_en = "English" -success = "Success" -theme_dark = "Dark" -theme_light = "Light" -theme_toggle = "Theme Toggle" -unknown = "Unknown" - -[ui.common.badge] -admin_only = "Admin only" -command_only = "Command only" -system = "System" - -[ui.common.status] -active = "Active" -blocked = "Blocked" -failure = "Failure" -inactive = "Inactive" -ok = "Ok" -pending = "Pending" -success = "Success" - [test] key = "Test" diff --git a/orgfront/src/locales/ko.toml b/orgfront/src/locales/ko.toml index 2b9503b0..d43657ef 100644 --- a/orgfront/src/locales/ko.toml +++ b/orgfront/src/locales/ko.toml @@ -302,15 +302,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다." [msg.admin.users.list.registry] count = "총 {{count}}명의 사용자가 등록되어 있습니다." -[msg.common] -error = "오류가 발생했습니다." -loading = "로딩 중..." -no_description = "설명이 없습니다." -parsing = "데이터 파싱 중..." -requesting = "요청 중..." -saving = "저장 중..." -unknown_error = "알 수 없는 오류" - [msg.dev] logout_confirm = "로그아웃 하시겠습니까?" @@ -1178,73 +1169,6 @@ name = "이름" role = "역할" -[ui.common] -add = "추가" -all = "전체" -admin_only = "관리자 전용" -assign = "할당" -back = "돌아가기" -back_to_login = "로그인으로 돌아가기" -cancel = "취소" -change_file = "파일 변경" -clear_search = "검색 초기화" -close = "닫기" -collapse = "접기" -confirm = "확인" -copy = "복사" -create = "생성" -delete = "삭제" -details = "상세정보" -edit = "편집" -export = "내보내기" -fail = "실패" -go_home = "홈으로" -view = "보기" -hyphen = "-" -manage = "관리" -na = "N/A" -never = "Never" -next = "다음" -none = "없음" -page_of = "Page {{page}} of {{total}}" -prev = "이전" -previous = "이전" -qr = "QR" -reset = "초기화" -read_only = "읽기 전용" -refresh = "새로고침" -remove = "제외" -resend = "재발송" -retry = "다시 시도" -save = "저장" -search = "검색" -select = "선택" -select_file = "파일 선택" -select_placeholder = "선택하세요" -show_more = "+ 더보기" -language = "언어" -language_ko = "한국어" -language_en = "English" -success = "성공" -theme_dark = "Dark" -theme_light = "Light" -theme_toggle = "테마 전환" -unknown = "Unknown" - -[ui.common.badge] -admin_only = "Admin only" -command_only = "Command only" -system = "System" - -[ui.common.status] -active = "활성" -blocked = "차단됨" -failure = "실패" -inactive = "비활성" -ok = "정상" -pending = "준비 중" -success = "성공" - [test] key = "테스트" diff --git a/orgfront/src/locales/template.toml b/orgfront/src/locales/template.toml index 8b3a86fd..f2fa8ccf 100644 --- a/orgfront/src/locales/template.toml +++ b/orgfront/src/locales/template.toml @@ -302,15 +302,6 @@ no_custom = "" [msg.admin.users.list.registry] count = "" -[msg.common] -error = "" -loading = "" -no_description = "" -parsing = "" -requesting = "" -saving = "" -unknown_error = "" - [msg.dev] logout_confirm = "" @@ -1177,73 +1168,6 @@ name = "" role = "" -[ui.common] -add = "" -all = "" -admin_only = "" -assign = "" -back = "" -back_to_login = "" -cancel = "" -change_file = "" -clear_search = "" -close = "" -collapse = "" -confirm = "" -copy = "" -create = "" -delete = "" -details = "" -edit = "" -export = "" -fail = "" -go_home = "" -view = "" -hyphen = "" -manage = "" -na = "" -never = "" -next = "" -none = "" -page_of = "" -prev = "" -previous = "" -qr = "" -reset = "" -read_only = "" -refresh = "" -remove = "" -resend = "" -retry = "" -save = "" -search = "" -select = "" -select_file = "" -select_placeholder = "" -show_more = "" -language = "" -language_ko = "" -language_en = "" -success = "" -theme_dark = "" -theme_light = "" -theme_toggle = "" -unknown = "" - -[ui.common.badge] -admin_only = "" -command_only = "" -system = "" - -[ui.common.status] -active = "" -blocked = "" -failure = "" -inactive = "" -ok = "" -pending = "" -success = "" - [test] key = "" From 27f48baadc2ee5c98010b65f874d1426d7fc05b6 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 11:42:47 +0900 Subject: [PATCH 03/21] =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88/?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=ED=99=98=EA=B2=BD=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index 03bc7874..fee5954c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -62,6 +62,7 @@ services: - "${ADMINFRONT_PORT:-5173}:5173" volumes: - ./adminfront:/app + - ./common:/common - ./locales:/locales - /app/node_modules networks: @@ -82,6 +83,7 @@ services: - "${DEVFRONT_PORT:-5174}:5173" volumes: - ./devfront:/app + - ./common:/common - ./locales:/locales - /app/node_modules networks: @@ -102,6 +104,7 @@ services: - "${ORGFRONT_PORT:-5175}:5175" volumes: - ./orgfront:/app + - ./common:/common - ./locales:/locales - /app/node_modules networks: From c0c5a23dc10a9d8843f3cb6b3249675b907a9a33 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 11:57:10 +0900 Subject: [PATCH 04/21] =?UTF-8?q?common/locales=20=EA=B8=B0=EB=B0=98=20i18?= =?UTF-8?q?n=20=EC=8A=A4=EC=BA=90=EB=84=88=EC=99=80=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- common/locales/en.toml | 21 +++++ common/locales/ko.toml | 21 +++++ common/locales/template.toml | 21 +++++ docs/i18n.md | 10 ++- tools/i18n-scanner/index.js | 143 +++++++++++++++++++------------ tools/i18n-scanner/report.js | 159 +++++++++++++++++++++-------------- 7 files changed, 261 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index a9559c83..5b62f029 100644 --- a/README.md +++ b/README.md @@ -541,11 +541,12 @@ KETO_WRITE_URL = "http://keto:4467" ``` ## 🌐 i18n 구조 (간략) -- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다. -- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다. +- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다. +- **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml`은 `ui.common.*`, `msg.common.*` 같은 React 공통 문구 레이어입니다. +- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`를 사용하며 `common locale -> app locale override` 순서로 TOML을 `?raw` 로드합니다. - **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다. - **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다. -- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다. +- **검증**: `node tools/i18n-scanner/index.js`로 `root locales`와 `common/locales`의 코드-키-로케일 동기화 상태를 함께 점검합니다. ## 🧪 Code Check CI 워크플로우 파일: `.gitea/workflows/code_check.yml` diff --git a/common/locales/en.toml b/common/locales/en.toml index 73d530b6..e27eff98 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -1,6 +1,9 @@ [msg.common] +copied = "Copied." error = "Error" +forbidden = "Access denied." loading = "Loading..." +no_results = "No results found." no_description = "No Description." parsing = "Parsing data..." requesting = "Requesting..." @@ -8,9 +11,11 @@ saving = "Saving..." unknown_error = "unknown error" [ui.common] +actions = "Actions" add = "Add" all = "All" admin_only = "Admin Only" +approve = "Approve" assign = "Assign" back = "Back" back_to_login = "Back to login" @@ -20,19 +25,27 @@ clear_search = "Clear Search" close = "Close" collapse = "Collapse" confirm = "Confirm" +continue = "Continue" copy = "Copy" create = "Create" delete = "Delete" +detail = "Detail" details = "Details" disabled = "Disabled" edit = "Edit" enabled = "Enabled" export = "Export" +export_with_ids = "Include UUID" +export_without_ids = "Export without UUID" fail = "Fail" go_home = "Go Home" +info = "Info" view = "View" hyphen = "-" +loading = "Loading..." manage = "Manage" +move = "Move" +move_org = "Move to another organization" na = "N/A" never = "Never" next = "Next" @@ -41,14 +54,19 @@ page_of = "Page {{page}} of {{total}}" prev = "Prev" previous = "Previous" qr = "QR" +reject = "Reject" +rejected = "Rejected" reset = "Reset" read_only = "Read Only" refresh = "Refresh" remove = "Remove" +remove_org = "Remove from organization" resend = "Resend" retry = "Retry" +row = "Row" save = "Save" search = "Search" +search_group = "Search groups..." select = "Select" select_file = "Select File" select_placeholder = "Select Placeholder" @@ -56,10 +74,13 @@ show_more = "Show More" language = "Language" language_ko = "Korean" language_en = "English" +submit = "Submit" +submitting = "Submitting..." success = "Success" theme_dark = "Dark" theme_light = "Light" theme_toggle = "Theme Toggle" +unassigned = "Unassigned" unknown = "Unknown" [ui.common.badge] diff --git a/common/locales/ko.toml b/common/locales/ko.toml index f5cf7c74..7e86dd7b 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -1,6 +1,9 @@ [msg.common] +copied = "복사되었습니다." error = "오류가 발생했습니다." +forbidden = "접근 권한이 없습니다." loading = "로딩 중..." +no_results = "검색 결과가 없습니다." no_description = "설명이 없습니다." parsing = "데이터 파싱 중..." requesting = "요청 중..." @@ -8,9 +11,11 @@ saving = "저장 중..." unknown_error = "알 수 없는 오류" [ui.common] +actions = "액션" add = "추가" all = "전체" admin_only = "관리자 전용" +approve = "승인" assign = "할당" back = "돌아가기" back_to_login = "로그인으로 돌아가기" @@ -20,19 +25,27 @@ clear_search = "검색 초기화" close = "닫기" collapse = "접기" confirm = "확인" +continue = "계속 진행" copy = "복사" create = "생성" delete = "삭제" +detail = "상세보기" details = "상세정보" disabled = "사용 안 함" edit = "편집" enabled = "사용" export = "내보내기" +export_with_ids = "UUID 포함" +export_without_ids = "UUID 제외 내보내기" fail = "실패" go_home = "홈으로" +info = "상세 안내" view = "보기" hyphen = "-" +loading = "로딩 중..." manage = "관리" +move = "이동" +move_org = "타 조직으로 이동" na = "N/A" never = "Never" next = "다음" @@ -41,14 +54,19 @@ page_of = "Page {{page}} of {{total}}" prev = "이전" previous = "이전" qr = "QR" +reject = "반려" +rejected = "반려됨" reset = "초기화" read_only = "읽기 전용" refresh = "새로고침" remove = "제외" +remove_org = "조직에서 제외" resend = "재발송" retry = "다시 시도" +row = "행" save = "저장" search = "검색" +search_group = "그룹 검색..." select = "선택" select_file = "파일 선택" select_placeholder = "선택하세요" @@ -56,10 +74,13 @@ show_more = "+ 더보기" language = "언어" language_ko = "한국어" language_en = "English" +submit = "신청하기" +submitting = "제출 중..." success = "성공" theme_dark = "Dark" theme_light = "Light" theme_toggle = "테마 전환" +unassigned = "미배정" unknown = "Unknown" [ui.common.badge] diff --git a/common/locales/template.toml b/common/locales/template.toml index 1cb9e149..3c16a2b3 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -1,6 +1,9 @@ [msg.common] +copied = "" error = "" +forbidden = "" loading = "" +no_results = "" no_description = "" parsing = "" requesting = "" @@ -8,9 +11,11 @@ saving = "" unknown_error = "" [ui.common] +actions = "" add = "" all = "" admin_only = "" +approve = "" assign = "" back = "" back_to_login = "" @@ -20,19 +25,27 @@ clear_search = "" close = "" collapse = "" confirm = "" +continue = "" copy = "" create = "" delete = "" +detail = "" details = "" disabled = "" edit = "" enabled = "" export = "" +export_with_ids = "" +export_without_ids = "" fail = "" go_home = "" +info = "" view = "" hyphen = "" +loading = "" manage = "" +move = "" +move_org = "" na = "" never = "" next = "" @@ -41,14 +54,19 @@ page_of = "" prev = "" previous = "" qr = "" +reject = "" +rejected = "" reset = "" read_only = "" refresh = "" remove = "" +remove_org = "" resend = "" retry = "" +row = "" save = "" search = "" +search_group = "" select = "" select_file = "" select_placeholder = "" @@ -56,10 +74,13 @@ show_more = "" language = "" language_ko = "" language_en = "" +submit = "" +submitting = "" success = "" theme_dark = "" theme_light = "" theme_toggle = "" +unassigned = "" unknown = "" [ui.common.badge] diff --git a/docs/i18n.md b/docs/i18n.md index 26f0eaba..1361c9cf 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -119,14 +119,18 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다. ./scripts/sync_userfront_locales.sh ``` * 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다. +4. **React 공통 locale 레이어**: + * React 계열 프런트(`adminfront`, `devfront`, `orgfront`)는 `common/locales/*.toml`을 공통 문구 레이어로 사용합니다. + * 공통 key는 `ui.common.*`, `msg.common.*` 범위에만 둡니다. + * 각 앱의 `src/locales/*.toml`은 앱 전용 문구를 유지하고, 로딩 시 `common locale -> app locale override` 순서로 merge 합니다. 3. **CI 검증 (Verification)**: * **Level 1: 리소스 동기화 검사 (`template` vs `lang`)** - * `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다. + * `locales/*.toml`과 `common/locales/*.toml` 각각에 대해 `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다. * 누락 시 빌드 실패. * **Level 2: 코드 사용성 검사 (`code` vs `template`)** * 전체 프론트엔드 소스코드(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)를 스캔하여 번역 함수(`t('key')`, `'key'.tr()`)에 사용된 키를 추출합니다. - * **Missing Key**: 코드에는 있는데 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다. - * **Unused Key**: `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다. + * **Missing Key**: 코드에는 있는데 해당 레이어의 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다. + * **Unused Key**: 각 `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다. #### 5.2.3 React (Admin/Dev) 구현 가이드 * **패키지 설치**: diff --git a/tools/i18n-scanner/index.js b/tools/i18n-scanner/index.js index b7d6bdc9..27e8993d 100644 --- a/tools/i18n-scanner/index.js +++ b/tools/i18n-scanner/index.js @@ -5,11 +5,27 @@ const fs = require('fs'); const path = require('path'); const ROOT_DIR = process.cwd(); -const LOCALES_DIR = path.join(ROOT_DIR, 'locales'); -const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml'); -const LANG_FILES = ['ko.toml', 'en.toml']; const FAIL_UNUSED = process.argv.includes('--fail-unused'); +const LOCALE_SPECS = [ + { + name: 'root', + label: 'root locales', + dir: path.join(ROOT_DIR, 'locales'), + template: 'template.toml', + langs: ['ko.toml', 'en.toml'], + ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'), + }, + { + name: 'common', + label: 'common locales', + dir: path.join(ROOT_DIR, 'common', 'locales'), + template: 'template.toml', + langs: ['ko.toml', 'en.toml'], + ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'), + }, +]; + const SKIP_DIRS = new Set([ '.git', 'node_modules', @@ -78,7 +94,6 @@ function parseTomlKeys(filePath) { continue; } - // Strip quotes if present if (key.startsWith('"') && key.endsWith('"')) { key = key.slice(1, -1); } @@ -138,6 +153,23 @@ function collectCodeKeys() { return keys; } +function shouldIgnoreCodeKey(key) { + return ( + key.includes('.msg.') || + key.includes('.ui.') || + key.includes('.err.') || + key.includes('.test.') || + key.includes('.non.') || + key.startsWith('ui.admin.users.list.table.') || + key.startsWith('msg.admin.users.detail.') || + key.startsWith('msg.dev.clients.') || + key.startsWith('ui.admin.users.create.') || + key.startsWith('ui.admin.users.detail.') || + key.startsWith('ui.dev.clients.') || + key.startsWith('ui.dev.session.') + ); +} + function difference(aSet, bSet) { const result = []; for (const item of aSet) { @@ -158,72 +190,75 @@ function printList(title, items) { } } -function main() { - const errors = []; - const warnings = []; - - const templateResult = parseTomlKeys(TEMPLATE_PATH); +function collectSpecResources(spec) { + const templatePath = path.join(spec.dir, spec.template); + const templateResult = parseTomlKeys(templatePath); if (!templateResult.ok) { - errors.push(templateResult.error); + return { ok: false, error: templateResult.error }; } const langKeyMap = new Map(); - for (const fileName of LANG_FILES) { - const langPath = path.join(LOCALES_DIR, fileName); + for (const fileName of spec.langs) { + const langPath = path.join(spec.dir, fileName); const langResult = parseTomlKeys(langPath); if (!langResult.ok) { - errors.push(langResult.error); - continue; + return { ok: false, error: langResult.error }; } langKeyMap.set(fileName, langResult.keys); } - if (errors.length > 0) { - console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.'); - for (const error of errors) { - console.error(`- ${error}`); - } - process.exit(1); - } + return { + ok: true, + templateKeys: templateResult.keys, + langKeyMap, + }; +} - const templateKeys = templateResult.keys; - const rawCodeKeys = Array.from(collectCodeKeys()); - const codeKeysArray = rawCodeKeys.filter(k => - !k.includes('.msg.') && - !k.includes('.ui.') && - !k.includes('.err.') && - !k.includes('.test.') && - !k.includes('.non.') && - !k.startsWith("ui.admin.users.list.table.") && - !k.startsWith("msg.admin.users.detail.") && - !k.startsWith("msg.common.") && - !k.startsWith("msg.dev.clients.") && - !k.startsWith("ui.admin.users.create.") && - !k.startsWith("ui.admin.users.detail.") && - !k.startsWith("ui.common.") && - !k.startsWith("ui.dev.clients.") && - !k.startsWith("ui.dev.session.") +function main() { + const errors = []; + const warnings = []; + + const rawCodeKeys = Array.from(collectCodeKeys()).filter( + (key) => !shouldIgnoreCodeKey(key), ); - const codeKeys = new Set(codeKeysArray); + const codeKeys = new Set(rawCodeKeys); - for (const [fileName, langKeys] of langKeyMap.entries()) { - const missingInLang = difference(templateKeys, langKeys); - if (missingInLang.length > 0) { - errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`); - printList(`${fileName}에 없는 키`, missingInLang); + for (const spec of LOCALE_SPECS) { + const resources = collectSpecResources(spec); + if (!resources.ok) { + errors.push(resources.error); + continue; } - } - const missingInTemplate = difference(codeKeys, templateKeys); - if (missingInTemplate.length > 0) { - errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`); - printList('template.toml에 없는 코드 사용 키', missingInTemplate); - } + for (const [fileName, langKeys] of resources.langKeyMap.entries()) { + const missingInLang = difference(resources.templateKeys, langKeys); + if (missingInLang.length > 0) { + errors.push( + `[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`, + ); + printList(`${spec.label} ${fileName}에 없는 키`, missingInLang); + } + } - const unusedInTemplate = difference(templateKeys, codeKeys); - if (unusedInTemplate.length > 0) { - warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`); - printList('코드에서 사용되지 않는 키', unusedInTemplate); + const ownedCodeKeys = new Set( + rawCodeKeys.filter((key) => spec.ownsKey(key)), + ); + + const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys); + if (missingInTemplate.length > 0) { + errors.push( + `[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}개`, + ); + printList(`${spec.label} template.toml에 없는 코드 사용 키`, missingInTemplate); + } + + const unusedInTemplate = difference(resources.templateKeys, codeKeys); + if (unusedInTemplate.length > 0) { + warnings.push( + `[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}개`, + ); + printList(`${spec.label} 코드에서 사용되지 않는 키`, unusedInTemplate); + } } if (errors.length > 0) { diff --git a/tools/i18n-scanner/report.js b/tools/i18n-scanner/report.js index ace4b1a2..190b451e 100644 --- a/tools/i18n-scanner/report.js +++ b/tools/i18n-scanner/report.js @@ -5,9 +5,25 @@ const fs = require('fs'); const path = require('path'); const ROOT_DIR = process.cwd(); -const LOCALES_DIR = path.join(ROOT_DIR, 'locales'); -const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml'); -const LANG_FILES = ['ko.toml', 'en.toml']; + +const LOCALE_SPECS = [ + { + name: 'root', + label: 'root locales', + dir: path.join(ROOT_DIR, 'locales'), + template: 'template.toml', + langs: ['ko.toml', 'en.toml'], + ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'), + }, + { + name: 'common', + label: 'common locales', + dir: path.join(ROOT_DIR, 'common', 'locales'), + template: 'template.toml', + langs: ['ko.toml', 'en.toml'], + ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'), + }, +]; const SKIP_DIRS = new Set([ '.git', @@ -81,7 +97,6 @@ function parseTomlKeys(filePath) { continue; } - // Strip quotes if present if (key.startsWith('"') && key.endsWith('"')) { key = key.slice(1, -1); } @@ -141,22 +156,20 @@ function collectCodeKeys() { return keys; } -function filterCodeKeys(rawKeys) { - return Array.from(rawKeys).filter((k) => - !k.includes('.msg.') && - !k.includes('.ui.') && - !k.includes('.err.') && - !k.includes('.test.') && - !k.includes('.non.') && - !k.startsWith('ui.admin.users.list.table.') && - !k.startsWith('msg.admin.users.detail.') && - !k.startsWith('msg.common.') && - !k.startsWith('msg.dev.clients.') && - !k.startsWith('ui.admin.users.create.') && - !k.startsWith('ui.admin.users.detail.') && - !k.startsWith('ui.common.') && - !k.startsWith('ui.dev.clients.') && - !k.startsWith('ui.dev.session.') +function shouldIgnoreCodeKey(key) { + return ( + key.includes('.msg.') || + key.includes('.ui.') || + key.includes('.err.') || + key.includes('.test.') || + key.includes('.non.') || + key.startsWith('ui.admin.users.list.table.') || + key.startsWith('msg.admin.users.detail.') || + key.startsWith('msg.dev.clients.') || + key.startsWith('ui.admin.users.create.') || + key.startsWith('ui.admin.users.detail.') || + key.startsWith('ui.dev.clients.') || + key.startsWith('ui.dev.session.') ); } @@ -170,62 +183,82 @@ function difference(aSet, bSet) { return result.sort(); } +function collectSpecResources(spec) { + const templatePath = path.join(spec.dir, spec.template); + const templateResult = parseTomlKeys(templatePath); + if (!templateResult.ok) { + return { ok: false, error: templateResult.error }; + } + + const langKeyMap = new Map(); + for (const fileName of spec.langs) { + const langPath = path.join(spec.dir, fileName); + const langResult = parseTomlKeys(langPath); + if (!langResult.ok) { + return { ok: false, error: langResult.error }; + } + langKeyMap.set(fileName, langResult.keys); + } + + return { + ok: true, + templateKeys: templateResult.keys, + langKeyMap, + }; +} + function buildReport() { const report = { generated_at: new Date().toISOString(), errors: [], warnings: [], - details: { + details: {}, + }; + + const rawCodeKeys = Array.from(collectCodeKeys()).filter( + (key) => !shouldIgnoreCodeKey(key), + ); + const codeKeys = new Set(rawCodeKeys); + + for (const spec of LOCALE_SPECS) { + const resources = collectSpecResources(spec); + report.details[spec.name] = { missing_in_template: [], missing_in_lang: {}, unused_in_template: [], - }, - }; + }; - const templateResult = parseTomlKeys(TEMPLATE_PATH); - if (!templateResult.ok) { - report.errors.push(templateResult.error); - return report; - } - - const templateKeys = templateResult.keys; - const codeKeys = new Set(filterCodeKeys(collectCodeKeys())); - - const langKeyMap = new Map(); - for (const fileName of LANG_FILES) { - const langPath = path.join(LOCALES_DIR, fileName); - const langResult = parseTomlKeys(langPath); - if (!langResult.ok) { - report.errors.push(langResult.error); + if (!resources.ok) { + report.errors.push(resources.error); continue; } - langKeyMap.set(fileName, langResult.keys); - } - for (const [fileName, langKeys] of langKeyMap.entries()) { - const missingInLang = difference(templateKeys, langKeys); - if (missingInLang.length > 0) { - report.errors.push( - `[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`, - ); - report.details.missing_in_lang[fileName] = missingInLang; + for (const [fileName, langKeys] of resources.langKeyMap.entries()) { + const missingInLang = difference(resources.templateKeys, langKeys); + if (missingInLang.length > 0) { + report.errors.push( + `[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`, + ); + report.details[spec.name].missing_in_lang[fileName] = missingInLang; + } } - } - const missingInTemplate = difference(codeKeys, templateKeys); - if (missingInTemplate.length > 0) { - report.errors.push( - `[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`, - ); - report.details.missing_in_template = missingInTemplate; - } + const ownedCodeKeys = new Set(rawCodeKeys.filter((key) => spec.ownsKey(key))); + const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys); + if (missingInTemplate.length > 0) { + report.errors.push( + `[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}개`, + ); + report.details[spec.name].missing_in_template = missingInTemplate; + } - const unusedInTemplate = difference(templateKeys, codeKeys); - if (unusedInTemplate.length > 0) { - report.warnings.push( - `[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`, - ); - report.details.unused_in_template = unusedInTemplate; + const unusedInTemplate = difference(resources.templateKeys, codeKeys); + if (unusedInTemplate.length > 0) { + report.warnings.push( + `[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}개`, + ); + report.details[spec.name].unused_in_template = unusedInTemplate; + } } return report; @@ -258,8 +291,12 @@ function main() { fs.writeFileSync(summaryPath, lines.join('\n')); if (report.errors.length > 0) { + console.error('❌ i18n report generated with errors'); process.exit(1); } + + console.log(`✅ i18n report written to ${outPath}`); + console.log(`✅ i18n summary written to ${summaryPath}`); } main(); From d371bd32c87dfbd42738842c28863a05508c906f Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 13:49:29 +0900 Subject: [PATCH 05/21] =?UTF-8?q?common=20query=20client=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=98=B5=EC=85=98=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/queryClient.ts | 10 +++------- common/core/query/queryClient.ts | 7 +++++++ devfront/src/app/queryClient.ts | 10 +++------- orgfront/src/app/queryClient.ts | 10 +++------- 4 files changed, 16 insertions(+), 21 deletions(-) create mode 100644 common/core/query/queryClient.ts diff --git a/adminfront/src/app/queryClient.ts b/adminfront/src/app/queryClient.ts index 06c9afde..9064efe8 100644 --- a/adminfront/src/app/queryClient.ts +++ b/adminfront/src/app/queryClient.ts @@ -1,11 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; +import { queryClientDefaultOptions } from "../../../common/core/query/queryClient"; + export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - refetchOnWindowFocus: false, - retry: 1, - }, - }, + defaultOptions: queryClientDefaultOptions, }); diff --git a/common/core/query/queryClient.ts b/common/core/query/queryClient.ts new file mode 100644 index 00000000..d82db9bf --- /dev/null +++ b/common/core/query/queryClient.ts @@ -0,0 +1,7 @@ +export const queryClientDefaultOptions = { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: 1, + }, +} as const; diff --git a/devfront/src/app/queryClient.ts b/devfront/src/app/queryClient.ts index 06c9afde..9064efe8 100644 --- a/devfront/src/app/queryClient.ts +++ b/devfront/src/app/queryClient.ts @@ -1,11 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; +import { queryClientDefaultOptions } from "../../../common/core/query/queryClient"; + export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - refetchOnWindowFocus: false, - retry: 1, - }, - }, + defaultOptions: queryClientDefaultOptions, }); diff --git a/orgfront/src/app/queryClient.ts b/orgfront/src/app/queryClient.ts index 06c9afde..9064efe8 100644 --- a/orgfront/src/app/queryClient.ts +++ b/orgfront/src/app/queryClient.ts @@ -1,11 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; +import { queryClientDefaultOptions } from "../../../common/core/query/queryClient"; + export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - refetchOnWindowFocus: false, - retry: 1, - }, - }, + defaultOptions: queryClientDefaultOptions, }); From 0655206f053452533cf629556b057ff30d29e980 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 13:50:14 +0900 Subject: [PATCH 06/21] =?UTF-8?q?common=20utils=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A1=9C=20cn=20helper=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/lib/utils.ts | 4 +++- common/core/utils/index.ts | 6 ++++++ devfront/src/lib/utils.ts | 4 +++- orgfront/src/lib/utils.ts | 4 +++- 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 common/core/utils/index.ts diff --git a/adminfront/src/lib/utils.ts b/adminfront/src/lib/utils.ts index 084e99ce..93c113b3 100644 --- a/adminfront/src/lib/utils.ts +++ b/adminfront/src/lib/utils.ts @@ -1,8 +1,10 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import { mergeClassNames } from "../../../common/core/utils"; + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return mergeClassNames(twMerge, [clsx(inputs)]); } export function generateSecurePassword(length = 16): string { diff --git a/common/core/utils/index.ts b/common/core/utils/index.ts new file mode 100644 index 00000000..010050f4 --- /dev/null +++ b/common/core/utils/index.ts @@ -0,0 +1,6 @@ +export function mergeClassNames( + mergeFn: (...classNames: string[]) => string, + classNames: string[], +) { + return mergeFn(...classNames); +} diff --git a/devfront/src/lib/utils.ts b/devfront/src/lib/utils.ts index 365058ce..8715786b 100644 --- a/devfront/src/lib/utils.ts +++ b/devfront/src/lib/utils.ts @@ -1,6 +1,8 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import { mergeClassNames } from "../../../common/core/utils"; + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return mergeClassNames(twMerge, [clsx(inputs)]); } diff --git a/orgfront/src/lib/utils.ts b/orgfront/src/lib/utils.ts index 365058ce..8715786b 100644 --- a/orgfront/src/lib/utils.ts +++ b/orgfront/src/lib/utils.ts @@ -1,6 +1,8 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import { mergeClassNames } from "../../../common/core/utils"; + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return mergeClassNames(twMerge, [clsx(inputs)]); } From 1419c8db273610a0fb419b65282406e5fe154ecf Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 14:11:36 +0900 Subject: [PATCH 07/21] =?UTF-8?q?gitkeep=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/core/query/.gitkeep | 1 - common/core/utils/.gitkeep | 1 - 2 files changed, 2 deletions(-) delete mode 100644 common/core/query/.gitkeep delete mode 100644 common/core/utils/.gitkeep diff --git a/common/core/query/.gitkeep b/common/core/query/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/common/core/query/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/common/core/utils/.gitkeep b/common/core/utils/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/common/core/utils/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - From 1c083dd58647d06e6ff745c43377a33ef7ce561b Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 14:58:28 +0900 Subject: [PATCH 08/21] =?UTF-8?q?common=20auth/session=20bootstrap?= =?UTF-8?q?=EA=B3=BC=20renew=20policy=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/lib/auth.ts | 32 +++----- adminfront/src/lib/sessionSliding.ts | 112 ++------------------------- common/core/auth/.gitkeep | 1 - common/core/auth/index.ts | 63 +++++++++++++++ common/core/session/.gitkeep | 1 - common/core/session/index.ts | 65 ++++++++++++++++ devfront/src/lib/auth.ts | 33 +++----- devfront/src/lib/sessionSliding.ts | 82 ++------------------ orgfront/src/lib/auth.ts | 33 +++----- orgfront/src/lib/sessionSliding.ts | 93 ++++++---------------- 10 files changed, 198 insertions(+), 317 deletions(-) delete mode 100644 common/core/auth/.gitkeep create mode 100644 common/core/auth/index.ts delete mode 100644 common/core/session/.gitkeep create mode 100644 common/core/session/index.ts diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts index 60a0e600..58224b6b 100644 --- a/adminfront/src/lib/auth.ts +++ b/adminfront/src/lib/auth.ts @@ -1,31 +1,23 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts"; import type { AuthProviderProps } from "react-oidc-context"; import { - buildAdminAuthRedirectUris, - resolveAdminPublicOrigin, -} from "./authConfig"; + buildCommonOidcRuntimeConfig, + buildCommonUserManagerSettings, +} from "../../../common/core/auth"; +import { resolveAdminPublicOrigin } from "./authConfig"; const adminPublicOrigin = resolveAdminPublicOrigin( import.meta.env.VITE_ADMIN_PUBLIC_URL, window.location.origin, ); -const adminRedirectUris = buildAdminAuthRedirectUris(adminPublicOrigin); -export const oidcConfig: AuthProviderProps = { - authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL - client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront", - redirect_uri: adminRedirectUris.redirectUri, - response_type: "code", - scope: "openid offline_access profile email", // offline_access for refresh token - post_logout_redirect_uri: adminRedirectUris.postLogoutRedirectUri, - popup_redirect_uri: adminRedirectUris.popupRedirectUri, +export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({ + authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", + clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront", + origin: adminPublicOrigin, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: false, -}; - -export const userManager = new UserManager({ - ...oidcConfig, - authority: oidcConfig.authority || "", - client_id: oidcConfig.client_id || "", - redirect_uri: oidcConfig.redirect_uri || "", }); + +export const userManager = new UserManager( + buildCommonUserManagerSettings(oidcConfig), +); diff --git a/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts index 9fd60fda..389fcef1 100644 --- a/adminfront/src/lib/sessionSliding.ts +++ b/adminfront/src/lib/sessionSliding.ts @@ -1,106 +1,6 @@ -export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000; -export const SESSION_RENEW_THROTTLE_MS = 30 * 1000; - -type SlidingSessionRenewDecisionParams = { - expiresAtSec?: number | null; - nowMs: number; - isEnabled: boolean; - isAuthenticated: boolean; - isLoading: boolean; - isRenewInFlight: boolean; - lastAttemptAtMs: number; - thresholdMs?: number; - throttleMs?: number; -}; - -export function shouldAttemptSlidingSessionRenew({ - expiresAtSec, - nowMs, - isEnabled, - isAuthenticated, - isLoading, - isRenewInFlight, - lastAttemptAtMs, - thresholdMs = SESSION_RENEW_THRESHOLD_MS, - throttleMs = SESSION_RENEW_THROTTLE_MS, -}: SlidingSessionRenewDecisionParams) { - if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { - return false; - } - - if (typeof expiresAtSec !== "number") { - console.debug( - "[sessionSliding] expiresAtSec is not a number, skipping renew", - ); - return false; - } - - const remainingMs = expiresAtSec * 1000 - nowMs; - const remainingMin = Math.floor(remainingMs / 1000 / 60); - - if (remainingMs <= 0) { - console.debug("[sessionSliding] Session already expired, skipping renew"); - return false; - } - - if (remainingMs > thresholdMs) { - return false; - } - - if (nowMs - lastAttemptAtMs < throttleMs) { - console.debug("[sessionSliding] Throttling renewal attempt"); - return false; - } - - console.info( - `[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`, - ); - return true; -} - -export function shouldAttemptUnlimitedSessionRenew({ - expiresAtSec, - nowMs, - isEnabled, - isAuthenticated, - isLoading, - isRenewInFlight, - lastAttemptAtMs, - thresholdMs = SESSION_RENEW_THRESHOLD_MS, - throttleMs = SESSION_RENEW_THROTTLE_MS, -}: SlidingSessionRenewDecisionParams) { - if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { - return false; - } - - if (typeof expiresAtSec !== "number") { - console.debug( - "[sessionSliding] expiresAtSec is not a number, skipping unlimited renew", - ); - return false; - } - - const remainingMs = expiresAtSec * 1000 - nowMs; - const remainingMin = Math.floor(remainingMs / 1000 / 60); - - if (remainingMs <= 0) { - console.debug( - "[sessionSliding] Session already expired, skipping unlimited renew", - ); - return false; - } - - if (remainingMs > thresholdMs) { - return false; - } - - if (nowMs - lastAttemptAtMs < throttleMs) { - console.debug("[sessionSliding] Throttling unlimited renewal attempt"); - return false; - } - - console.info( - `[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`, - ); - return true; -} +export { + DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS, + DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS, + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../../common/core/session"; diff --git a/common/core/auth/.gitkeep b/common/core/auth/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/common/core/auth/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/common/core/auth/index.ts b/common/core/auth/index.ts new file mode 100644 index 00000000..41e0f4e7 --- /dev/null +++ b/common/core/auth/index.ts @@ -0,0 +1,63 @@ +export const DEFAULT_OIDC_SCOPE = "openid offline_access profile email"; +export const DEFAULT_OIDC_REDIRECT_PATH = "/auth/callback"; + +export type CommonOidcConfigOptions = { + authority: string; + clientId: string; + origin?: string; + redirectPath?: string; + scope?: string; + automaticSilentRenew?: boolean; + userStore: TUserStore; +}; + +type CommonOidcRuntimeConfig = { + authority: string; + client_id: string; + redirect_uri: string; + response_type: "code"; + scope: string; + post_logout_redirect_uri: string; + popup_redirect_uri: string; + userStore: TUserStore; + automaticSilentRenew: boolean; +}; + +export function buildCommonOidcRuntimeConfig({ + authority, + clientId, + origin = window.location.origin, + redirectPath = DEFAULT_OIDC_REDIRECT_PATH, + scope = DEFAULT_OIDC_SCOPE, + automaticSilentRenew = false, + userStore, +}: CommonOidcConfigOptions): CommonOidcRuntimeConfig { + const callbackUrl = `${origin}${redirectPath}`; + + return { + authority, + client_id: clientId, + redirect_uri: callbackUrl, + response_type: "code", + scope, + post_logout_redirect_uri: origin, + popup_redirect_uri: callbackUrl, + userStore, + automaticSilentRenew, + }; +} + +export function buildCommonUserManagerSettings< + TConfig extends { + authority?: string; + client_id?: string; + redirect_uri?: string; + }, +>(config: TConfig) { + return { + ...config, + authority: config.authority || "", + client_id: config.client_id || "", + redirect_uri: config.redirect_uri || "", + }; +} diff --git a/common/core/session/.gitkeep b/common/core/session/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/common/core/session/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/common/core/session/index.ts b/common/core/session/index.ts new file mode 100644 index 00000000..18ef3e07 --- /dev/null +++ b/common/core/session/index.ts @@ -0,0 +1,65 @@ +export const DEFAULT_SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000; +export const DEFAULT_SESSION_RENEW_THROTTLE_MS = 30 * 1000; + +export type SessionRenewDecisionParams = { + expiresAtSec?: number | null; + nowMs: number; + isEnabled: boolean; + isAuthenticated: boolean; + isLoading: boolean; + isRenewInFlight: boolean; + lastAttemptAtMs: number; + thresholdMs?: number; + throttleMs?: number; +}; + +function hasRenewPreconditions({ + isAuthenticated, + isLoading, + isRenewInFlight, +}: SessionRenewDecisionParams) { + return isAuthenticated && !isLoading && !isRenewInFlight; +} + +function isRenewWindowOpen({ + expiresAtSec, + nowMs, + lastAttemptAtMs, + thresholdMs = DEFAULT_SESSION_RENEW_THRESHOLD_MS, + throttleMs = DEFAULT_SESSION_RENEW_THROTTLE_MS, +}: SessionRenewDecisionParams) { + if (typeof expiresAtSec !== "number") { + return false; + } + + const remainingMs = expiresAtSec * 1000 - nowMs; + if (remainingMs <= 0 || remainingMs > thresholdMs) { + return false; + } + + if (nowMs - lastAttemptAtMs < throttleMs) { + return false; + } + + return true; +} + +export function shouldAttemptSlidingSessionRenew( + params: SessionRenewDecisionParams, +) { + if (!params.isEnabled || !hasRenewPreconditions(params)) { + return false; + } + + return isRenewWindowOpen(params); +} + +export function shouldAttemptUnlimitedSessionRenew( + params: SessionRenewDecisionParams, +) { + if (params.isEnabled || !hasRenewPreconditions(params)) { + return false; + } + + return isRenewWindowOpen(params); +} diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index 815d5c32..62768900 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -1,32 +1,23 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts"; import type { AuthProviderProps } from "react-oidc-context"; import { - buildDevFrontAuthRedirectUris, - resolveDevFrontPublicOrigin, -} from "./authConfig"; + buildCommonOidcRuntimeConfig, + buildCommonUserManagerSettings, +} from "../../../common/core/auth"; +import { resolveDevFrontPublicOrigin } from "./authConfig"; const devFrontPublicOrigin = resolveDevFrontPublicOrigin( import.meta.env.VITE_DEVFRONT_PUBLIC_URL, window.location.origin, ); -const devFrontRedirectUris = - buildDevFrontAuthRedirectUris(devFrontPublicOrigin); -export const oidcConfig: AuthProviderProps = { - authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL - client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront", - redirect_uri: devFrontRedirectUris.redirectUri, - response_type: "code", - scope: "openid offline_access profile email", // offline_access for refresh token - post_logout_redirect_uri: devFrontRedirectUris.postLogoutRedirectUri, - popup_redirect_uri: devFrontRedirectUris.popupRedirectUri, +export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({ + authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", + clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront", + origin: devFrontPublicOrigin, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: false, -}; - -export const userManager = new UserManager({ - ...oidcConfig, - authority: oidcConfig.authority || "", - client_id: oidcConfig.client_id || "", - redirect_uri: oidcConfig.redirect_uri || "", }); + +export const userManager = new UserManager( + buildCommonUserManagerSettings(oidcConfig), +); diff --git a/devfront/src/lib/sessionSliding.ts b/devfront/src/lib/sessionSliding.ts index 831ef8e8..389fcef1 100644 --- a/devfront/src/lib/sessionSliding.ts +++ b/devfront/src/lib/sessionSliding.ts @@ -1,76 +1,6 @@ -export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000; -export const SESSION_RENEW_THROTTLE_MS = 30 * 1000; - -type SlidingSessionRenewDecisionParams = { - expiresAtSec?: number | null; - nowMs: number; - isEnabled: boolean; - isAuthenticated: boolean; - isLoading: boolean; - isRenewInFlight: boolean; - lastAttemptAtMs: number; - thresholdMs?: number; - throttleMs?: number; -}; - -export function shouldAttemptSlidingSessionRenew({ - expiresAtSec, - nowMs, - isEnabled, - isAuthenticated, - isLoading, - isRenewInFlight, - lastAttemptAtMs, - thresholdMs = SESSION_RENEW_THRESHOLD_MS, - throttleMs = SESSION_RENEW_THROTTLE_MS, -}: SlidingSessionRenewDecisionParams) { - if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { - return false; - } - - if (typeof expiresAtSec !== "number") { - return false; - } - - const remainingMs = expiresAtSec * 1000 - nowMs; - if (remainingMs <= 0 || remainingMs > thresholdMs) { - return false; - } - - if (nowMs - lastAttemptAtMs < throttleMs) { - return false; - } - - return true; -} - -export function shouldAttemptUnlimitedSessionRenew({ - expiresAtSec, - nowMs, - isEnabled, - isAuthenticated, - isLoading, - isRenewInFlight, - lastAttemptAtMs, - thresholdMs = SESSION_RENEW_THRESHOLD_MS, - throttleMs = SESSION_RENEW_THROTTLE_MS, -}: SlidingSessionRenewDecisionParams) { - if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { - return false; - } - - if (typeof expiresAtSec !== "number") { - return false; - } - - const remainingMs = expiresAtSec * 1000 - nowMs; - if (remainingMs <= 0 || remainingMs > thresholdMs) { - return false; - } - - if (nowMs - lastAttemptAtMs < throttleMs) { - return false; - } - - return true; -} +export { + DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS, + DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS, + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../../common/core/session"; diff --git a/orgfront/src/lib/auth.ts b/orgfront/src/lib/auth.ts index 06d2762c..49cadbfd 100644 --- a/orgfront/src/lib/auth.ts +++ b/orgfront/src/lib/auth.ts @@ -1,33 +1,24 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts"; import type { AuthProviderProps } from "react-oidc-context"; import { - buildOrgFrontAuthRedirectUris, - resolveOrgFrontPublicOrigin, -} from "./authConfig"; + buildCommonOidcRuntimeConfig, + buildCommonUserManagerSettings, +} from "../../../common/core/auth"; +import { resolveOrgFrontPublicOrigin } from "./authConfig"; const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin( import.meta.env.VITE_ORGFRONT_PUBLIC_URL, window.location.origin, ); -const orgFrontRedirectUris = - buildOrgFrontAuthRedirectUris(orgFrontPublicOrigin); -export const oidcConfig: AuthProviderProps = { +export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({ authority: - import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL - client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront", - redirect_uri: orgFrontRedirectUris.redirectUri, - response_type: "code", - scope: "openid offline_access profile email", // offline_access for refresh token - post_logout_redirect_uri: orgFrontRedirectUris.postLogoutRedirectUri, - popup_redirect_uri: orgFrontRedirectUris.popupRedirectUri, + import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", + clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront", + origin: orgFrontPublicOrigin, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: false, -}; - -export const userManager = new UserManager({ - ...oidcConfig, - authority: oidcConfig.authority || "", - client_id: oidcConfig.client_id || "", - redirect_uri: oidcConfig.redirect_uri || "", }); + +export const userManager = new UserManager( + buildCommonUserManagerSettings(oidcConfig), +); diff --git a/orgfront/src/lib/sessionSliding.ts b/orgfront/src/lib/sessionSliding.ts index be152778..6bb88ecd 100644 --- a/orgfront/src/lib/sessionSliding.ts +++ b/orgfront/src/lib/sessionSliding.ts @@ -1,76 +1,27 @@ +import { + DEFAULT_SESSION_RENEW_THROTTLE_MS, + shouldAttemptSlidingSessionRenew as shouldAttemptSlidingSessionRenewBase, + shouldAttemptUnlimitedSessionRenew as shouldAttemptUnlimitedSessionRenewBase, + type SessionRenewDecisionParams, +} from "../../../common/core/session"; + +export const SESSION_RENEW_THROTTLE_MS = DEFAULT_SESSION_RENEW_THROTTLE_MS; export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000; -export const SESSION_RENEW_THROTTLE_MS = 30 * 1000; -type SlidingSessionRenewDecisionParams = { - expiresAtSec?: number | null; - nowMs: number; - isEnabled: boolean; - isAuthenticated: boolean; - isLoading: boolean; - isRenewInFlight: boolean; - lastAttemptAtMs: number; - thresholdMs?: number; - throttleMs?: number; -}; - -export function shouldAttemptSlidingSessionRenew({ - expiresAtSec, - nowMs, - isEnabled, - isAuthenticated, - isLoading, - isRenewInFlight, - lastAttemptAtMs, - thresholdMs = SESSION_RENEW_THRESHOLD_MS, - throttleMs = SESSION_RENEW_THROTTLE_MS, -}: SlidingSessionRenewDecisionParams) { - if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { - return false; - } - - if (typeof expiresAtSec !== "number") { - return false; - } - - const remainingMs = expiresAtSec * 1000 - nowMs; - if (remainingMs <= 0 || remainingMs > thresholdMs) { - return false; - } - - if (nowMs - lastAttemptAtMs < throttleMs) { - return false; - } - - return true; +export function shouldAttemptSlidingSessionRenew( + params: SessionRenewDecisionParams, +) { + return shouldAttemptSlidingSessionRenewBase({ + ...params, + thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS, + }); } -export function shouldAttemptUnlimitedSessionRenew({ - expiresAtSec, - nowMs, - isEnabled, - isAuthenticated, - isLoading, - isRenewInFlight, - lastAttemptAtMs, - thresholdMs = SESSION_RENEW_THRESHOLD_MS, - throttleMs = SESSION_RENEW_THROTTLE_MS, -}: SlidingSessionRenewDecisionParams) { - if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { - return false; - } - - if (typeof expiresAtSec !== "number") { - return false; - } - - const remainingMs = expiresAtSec * 1000 - nowMs; - if (remainingMs <= 0 || remainingMs > thresholdMs) { - return false; - } - - if (nowMs - lastAttemptAtMs < throttleMs) { - return false; - } - - return true; +export function shouldAttemptUnlimitedSessionRenew( + params: SessionRenewDecisionParams, +) { + return shouldAttemptUnlimitedSessionRenewBase({ + ...params, + thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS, + }); } From 7d7f17ab69258b919c84ac47492a7adefea18eb9 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 16:21:38 +0900 Subject: [PATCH 09/21] =?UTF-8?q?front=20=EA=B3=B5=ED=86=B5=20theme=20toke?= =?UTF-8?q?n=20=EB=B0=8F=20base=20style=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/index.css | 45 +++------------------------ common/theme/.gitkeep | 1 - common/theme/base.css | 64 ++++++++++++++++++++++++++++++++++++++ devfront/src/index.css | 45 +++------------------------ orgfront/src/index.css | 66 ++-------------------------------------- 5 files changed, 75 insertions(+), 146 deletions(-) delete mode 100644 common/theme/.gitkeep create mode 100644 common/theme/base.css diff --git a/adminfront/src/index.css b/adminfront/src/index.css index 20ef470f..61376c48 100644 --- a/adminfront/src/index.css +++ b/adminfront/src/index.css @@ -1,3 +1,5 @@ +@import "../../../common/theme/base.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -24,37 +26,8 @@ --input: 215 25% 24%; --ring: 209 79% 52%; --radius: 0.75rem; - } - - .light { - --background: 0 0% 98%; - --foreground: 223 25% 12%; - --card: 0 0% 100%; - --card-foreground: 223 25% 12%; - --popover: 0 0% 100%; - --popover-foreground: 223 25% 12%; - --primary: 209 79% 52%; - --primary-foreground: 0 0% 100%; - --secondary: 220 17% 94%; - --secondary-foreground: 223 25% 20%; - --muted: 223 15% 45%; - --muted-foreground: 223 15% 45%; - --accent: 40 96% 62%; - --accent-foreground: 223 25% 12%; - --destructive: 0 84% 60%; - --destructive-foreground: 0 0% 100%; - --border: 220 17% 90%; - --input: 220 17% 90%; - --ring: 209 79% 52%; - } - - * { - @apply border-border; - } - - body { - @apply min-h-screen bg-background font-sans text-foreground antialiased; - background-image: radial-gradient( + --app-background-image: + radial-gradient( circle at 10% 18%, rgba(54, 211, 153, 0.16), transparent 28% @@ -70,14 +43,4 @@ transparent 30% ); } - - a { - @apply text-inherit no-underline; - } -} - -@layer components { - .glass-panel { - @apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur; - } } diff --git a/common/theme/.gitkeep b/common/theme/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/common/theme/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/common/theme/base.css b/common/theme/base.css new file mode 100644 index 00000000..4dad08ee --- /dev/null +++ b/common/theme/base.css @@ -0,0 +1,64 @@ +@layer base { + .light { + --background: 0 0% 98%; + --foreground: 223 25% 12%; + --card: 0 0% 100%; + --card-foreground: 223 25% 12%; + --popover: 0 0% 100%; + --popover-foreground: 223 25% 12%; + --primary: 209 79% 52%; + --primary-foreground: 0 0% 100%; + --secondary: 220 17% 94%; + --secondary-foreground: 223 25% 20%; + --muted: 223 15% 45%; + --muted-foreground: 223 15% 45%; + --accent: 40 96% 62%; + --accent-foreground: 223 25% 12%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 100%; + --border: 220 17% 90%; + --input: 220 17% 90%; + --ring: 209 79% 52%; + } + + .dark { + --background: 210 25% 6%; + --foreground: 210 35% 96%; + --card: 215 32% 9%; + --card-foreground: 210 35% 96%; + --popover: 215 32% 9%; + --popover-foreground: 210 35% 96%; + --primary: 209 79% 52%; + --primary-foreground: 210 35% 96%; + --secondary: 215 25% 16%; + --secondary-foreground: 210 35% 96%; + --muted: 215 15% 65%; + --muted-foreground: 215 15% 65%; + --accent: 42 95% 57%; + --accent-foreground: 215 25% 10%; + --destructive: 0 84% 60%; + --destructive-foreground: 210 35% 96%; + --border: 215 25% 24%; + --input: 215 25% 24%; + --ring: 209 79% 52%; + } + + * { + @apply border-border; + } + + body { + @apply min-h-screen bg-background font-sans text-foreground antialiased; + background-image: var(--app-background-image, none); + } + + a { + @apply text-inherit no-underline; + } +} + +@layer components { + .glass-panel { + @apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur; + } +} diff --git a/devfront/src/index.css b/devfront/src/index.css index 20ef470f..61376c48 100644 --- a/devfront/src/index.css +++ b/devfront/src/index.css @@ -1,3 +1,5 @@ +@import "../../../common/theme/base.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -24,37 +26,8 @@ --input: 215 25% 24%; --ring: 209 79% 52%; --radius: 0.75rem; - } - - .light { - --background: 0 0% 98%; - --foreground: 223 25% 12%; - --card: 0 0% 100%; - --card-foreground: 223 25% 12%; - --popover: 0 0% 100%; - --popover-foreground: 223 25% 12%; - --primary: 209 79% 52%; - --primary-foreground: 0 0% 100%; - --secondary: 220 17% 94%; - --secondary-foreground: 223 25% 20%; - --muted: 223 15% 45%; - --muted-foreground: 223 15% 45%; - --accent: 40 96% 62%; - --accent-foreground: 223 25% 12%; - --destructive: 0 84% 60%; - --destructive-foreground: 0 0% 100%; - --border: 220 17% 90%; - --input: 220 17% 90%; - --ring: 209 79% 52%; - } - - * { - @apply border-border; - } - - body { - @apply min-h-screen bg-background font-sans text-foreground antialiased; - background-image: radial-gradient( + --app-background-image: + radial-gradient( circle at 10% 18%, rgba(54, 211, 153, 0.16), transparent 28% @@ -70,14 +43,4 @@ transparent 30% ); } - - a { - @apply text-inherit no-underline; - } -} - -@layer components { - .glass-panel { - @apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur; - } } diff --git a/orgfront/src/index.css b/orgfront/src/index.css index 16b46338..df0c3490 100644 --- a/orgfront/src/index.css +++ b/orgfront/src/index.css @@ -1,3 +1,5 @@ +@import "../../../common/theme/base.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -24,72 +26,10 @@ --input: 220 17% 90%; --ring: 209 79% 52%; --radius: 0.75rem; - } - - .light { - --background: 0 0% 98%; - --foreground: 223 25% 12%; - --card: 0 0% 100%; - --card-foreground: 223 25% 12%; - --popover: 0 0% 100%; - --popover-foreground: 223 25% 12%; - --primary: 209 79% 52%; - --primary-foreground: 0 0% 100%; - --secondary: 220 17% 94%; - --secondary-foreground: 223 25% 20%; - --muted: 223 15% 45%; - --muted-foreground: 223 15% 45%; - --accent: 40 96% 62%; - --accent-foreground: 223 25% 12%; - --destructive: 0 84% 60%; - --destructive-foreground: 0 0% 100%; - --border: 220 17% 90%; - --input: 220 17% 90%; - --ring: 209 79% 52%; - } - - .dark { - --background: 210 25% 6%; - --foreground: 210 35% 96%; - --card: 215 32% 9%; - --card-foreground: 210 35% 96%; - --popover: 215 32% 9%; - --popover-foreground: 210 35% 96%; - --primary: 209 79% 52%; - --primary-foreground: 210 35% 96%; - --secondary: 215 25% 16%; - --secondary-foreground: 210 35% 96%; - --muted: 215 15% 65%; - --muted-foreground: 215 15% 65%; - --accent: 42 95% 57%; - --accent-foreground: 215 25% 10%; - --destructive: 0 84% 60%; - --destructive-foreground: 210 35% 96%; - --border: 215 25% 24%; - --input: 215 25% 24%; - --ring: 209 79% 52%; - } - - * { - @apply border-border; - } - - body { - @apply min-h-screen bg-background font-sans text-foreground antialiased; - background-image: linear-gradient( + --app-background-image: linear-gradient( 180deg, hsl(var(--background)) 0%, hsl(var(--secondary) / 0.35) 100% ); } - - a { - @apply text-inherit no-underline; - } -} - -@layer components { - .glass-panel { - @apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur; - } } From 85e1a172dd24b54cc37e772b5175cd438356f89a Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 11 May 2026 17:12:46 +0900 Subject: [PATCH 10/21] =?UTF-8?q?common=20shell=20frame/state=20helper=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.tsx | 169 +++++++---------- common/shell/.gitkeep | 1 - common/shell/index.ts | 170 +++++++++++++++++ devfront/src/components/layout/AppLayout.tsx | 173 +++++++----------- orgfront/src/components/layout/AppLayout.tsx | 172 +++++++---------- 5 files changed, 362 insertions(+), 323 deletions(-) delete mode 100644 common/shell/.gitkeep create mode 100644 common/shell/index.ts diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 320361b7..8d169d8d 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -26,6 +26,15 @@ import { shouldAttemptSlidingSessionRenew, shouldAttemptUnlimitedSessionRenew, } from "../../lib/sessionSliding"; +import { + applyShellTheme, + buildShellProfileSummary, + buildShellSessionStatus, + readShellSessionExpiryEnabled, + readShellTheme, + shellLayoutClasses, + writeShellSessionExpiryEnabled, +} from "../../../../common/shell"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; @@ -62,15 +71,11 @@ function AppLayout() { const mockRoleOverride = isMockRoleEnabled ? window.localStorage.getItem("X-Mock-Role") : null; - const [theme, setTheme] = useState<"light" | "dark">(() => { - const stored = window.localStorage.getItem("admin_theme"); - return stored === "dark" ? "dark" : "light"; - }); + const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [isProfileOpen, setIsProfileOpen] = useState(false); - const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => { - const stored = window.localStorage.getItem("baron_session_expiry_enabled"); - return stored !== "false"; - }); + const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState( + readShellSessionExpiryEnabled, + ); const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { @@ -214,14 +219,7 @@ function AppLayout() { }, [auth.user]); useEffect(() => { - const root = document.documentElement; - root.classList.remove("light", "dark"); - if (theme === "light") { - root.classList.add("light"); - } else { - root.classList.add("dark"); - } - window.localStorage.setItem("admin_theme", theme); + applyShellTheme(theme); }, [theme]); useEffect(() => { @@ -388,68 +386,26 @@ function AppLayout() { setTheme((prev) => (prev === "light" ? "dark" : "light")); }; - const profileName = - profile?.name?.trim() || - auth.user?.profile.name?.toString().trim() || - auth.user?.profile.preferred_username?.toString().trim() || - t("ui.dev.profile.unknown_name", "Unknown User"); - const profileEmail = - profile?.email?.trim() || - auth.user?.profile.email?.toString().trim() || - t("ui.dev.profile.unknown_email", "unknown@example.com"); - const profileInitial = profileName.charAt(0).toUpperCase(); + const profileSummary = buildShellProfileSummary({ + profileName: + profile?.name || + auth.user?.profile.name?.toString() || + auth.user?.profile.preferred_username?.toString(), + profileEmail: profile?.email || auth.user?.profile.email?.toString(), + fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"), + fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"), + }); const profileRoleKey = mockRoleOverride || profile?.role || "user"; - const expiresAtSec = auth.user?.expires_at; - const remainingMs = - typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null; - const remainingTotalSec = - remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null; - const remainingMinutes = - remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null; - const remainingSeconds = - remainingTotalSec !== null ? remainingTotalSec % 60 : null; - - let sessionToneClass = - "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - let sessionText = t("ui.dev.session.active", "세션 활성"); - - if (remainingMs === null) { - sessionToneClass = "border-border bg-card text-muted-foreground"; - sessionText = t("ui.dev.session.unknown", "알 수 없음"); - } else if (remainingMs <= 0) { - sessionToneClass = - "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; - sessionText = t("ui.dev.session.expired", "세션 만료"); - } else if ( - remainingMinutes !== null && - remainingSeconds !== null && - remainingMinutes <= 5 - ) { - sessionToneClass = - "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; - sessionText = t( - "ui.dev.session.expiring", - "만료 임박: {{minutes}}분 {{seconds}}초 남음", - { - minutes: remainingMinutes, - seconds: remainingSeconds, - }, - ); - } else { - sessionText = t( - "ui.dev.session.remaining", - "만료 예정: {{minutes}}분 {{seconds}}초 남음", - { - minutes: remainingMinutes ?? 0, - seconds: remainingSeconds ?? 0, - }, - ); - } + const sessionStatus = buildShellSessionStatus({ + expiresAtSec: auth.user?.expires_at, + nowMs, + t, + }); const handleSessionExpiryToggle = () => { setIsSessionExpiryEnabled((prev) => { const next = !prev; - window.localStorage.setItem("baron_session_expiry_enabled", String(next)); + writeShellSessionExpiryEnabled(next); return next; }); }; @@ -463,11 +419,11 @@ function AppLayout() { } return ( -
-