@xfilecom/xframe 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/xframe.js CHANGED
@@ -38,10 +38,22 @@ Options:
38
38
  --front-core <spec> @xfilecom/front-core (default: ^0.2.1)
39
39
  --no-install 스캐폴드만 하고 yarn/npm install 생략
40
40
 
41
+ MySQL (shared/config/api/application.yml 의 database.*)
42
+ --skip-db-prompt TTY여도 DB 질문 생략 → 아래 기본값 또는 플래그만 반영
43
+ --db-host <h> 기본 127.0.0.1
44
+ --db-port <n> 기본 3306
45
+ --db-user <u> 기본 root
46
+ --db-password <p> 기본 빈 문자열 (빈 값: --db-password "")
47
+ --db-name <n> 기본 app
48
+ --db-pull install 성공 후 yarn/npm run db:pull 실행 (MySQL 도달 가능할 때)
49
+
50
+ TTY + --skip-db-prompt 없음 → 위 항목을 순서대로 물어봅니다 (Enter = 기본값).
51
+ 마지막에 db:pull 여부를 [y/N] 로 묻습니다 (--db-pull 이 있으면 질문 생략하고 실행).
52
+
41
53
  Examples:
42
54
  npx @xfilecom/xframe@latest my-app
43
- npx @xfilecom/xframe my-app --backend-core file:../../../packages/backend-core \\
44
- --front-core file:../../../packages/front-core`);
55
+ npx @xfilecom/xframe my-app --db-host 127.0.0.1 --db-user app --db-password secret --db-name mydb --db-pull
56
+ npx @xfilecom/xframe my-app --skip-db-prompt --no-install`);
45
57
  }
46
58
 
47
59
  function parseArgs(argv) {
@@ -50,6 +62,20 @@ function parseArgs(argv) {
50
62
  let backendCore = '^1.0.0';
51
63
  let frontCore = '^0.2.1';
52
64
  let skipInstall = false;
65
+ let skipDbPrompt = false;
66
+ let dbPull = false;
67
+ /** @type {{ host?: string, port?: number, user?: string, password?: string, name?: string }} */
68
+ const dbFromCli = {};
69
+
70
+ function takeStringFlag(name, i) {
71
+ const v = rest[i + 1];
72
+ if (v === undefined) {
73
+ console.error(`xframe: ${name} requires a value`);
74
+ process.exit(1);
75
+ }
76
+ i += 1;
77
+ return { val: v, nextI: i };
78
+ }
53
79
 
54
80
  for (let i = 0; i < rest.length; i += 1) {
55
81
  const a = rest[i];
@@ -61,24 +87,58 @@ function parseArgs(argv) {
61
87
  skipInstall = true;
62
88
  continue;
63
89
  }
90
+ if (a === '--skip-db-prompt') {
91
+ skipDbPrompt = true;
92
+ continue;
93
+ }
94
+ if (a === '--db-pull') {
95
+ dbPull = true;
96
+ continue;
97
+ }
64
98
  if (a === '--backend-core') {
65
- const v = rest[i + 1];
66
- if (!v || v.startsWith('-')) {
67
- console.error('xframe: --backend-core requires a value');
68
- process.exit(1);
69
- }
70
- backendCore = v;
71
- i += 1;
99
+ const { val, nextI } = takeStringFlag('--backend-core', i);
100
+ backendCore = val;
101
+ i = nextI;
72
102
  continue;
73
103
  }
74
104
  if (a === '--front-core') {
75
- const v = rest[i + 1];
76
- if (!v || v.startsWith('-')) {
77
- console.error('xframe: --front-core requires a value');
105
+ const { val, nextI } = takeStringFlag('--front-core', i);
106
+ frontCore = val;
107
+ i = nextI;
108
+ continue;
109
+ }
110
+ if (a === '--db-host') {
111
+ const { val, nextI } = takeStringFlag('--db-host', i);
112
+ dbFromCli.host = val;
113
+ i = nextI;
114
+ continue;
115
+ }
116
+ if (a === '--db-port') {
117
+ const { val, nextI } = takeStringFlag('--db-port', i);
118
+ dbFromCli.port = parseInt(val, 10);
119
+ if (!Number.isFinite(dbFromCli.port)) {
120
+ console.error('xframe: --db-port must be a number');
78
121
  process.exit(1);
79
122
  }
80
- frontCore = v;
81
- i += 1;
123
+ i = nextI;
124
+ continue;
125
+ }
126
+ if (a === '--db-user') {
127
+ const { val, nextI } = takeStringFlag('--db-user', i);
128
+ dbFromCli.user = val;
129
+ i = nextI;
130
+ continue;
131
+ }
132
+ if (a === '--db-password') {
133
+ const { val, nextI } = takeStringFlag('--db-password', i);
134
+ dbFromCli.password = val;
135
+ i = nextI;
136
+ continue;
137
+ }
138
+ if (a === '--db-name') {
139
+ const { val, nextI } = takeStringFlag('--db-name', i);
140
+ dbFromCli.name = val;
141
+ i = nextI;
82
142
  continue;
83
143
  }
84
144
  if (a.startsWith('-')) {
@@ -93,7 +153,126 @@ function parseArgs(argv) {
93
153
  projectDir = a;
94
154
  }
95
155
 
96
- return { projectDir, backendCore, frontCore, skipInstall };
156
+ return {
157
+ projectDir,
158
+ backendCore,
159
+ frontCore,
160
+ skipInstall,
161
+ skipDbPrompt,
162
+ dbPull,
163
+ dbFromCli,
164
+ };
165
+ }
166
+
167
+ /** YAML 단일 인용 내용 이스케이프 */
168
+ function yamlSingleQuotedEscape(s) {
169
+ return String(s).replace(/'/g, "''");
170
+ }
171
+
172
+ /** 식별자형이면 따옴표 없이, 아니면 작은따옴표 */
173
+ function yamlScalar(v) {
174
+ const s = String(v);
175
+ if (/^[A-Za-z0-9_.@-]+$/.test(s)) return s;
176
+ return `'${yamlSingleQuotedEscape(s)}'`;
177
+ }
178
+
179
+ /**
180
+ * 최상위 `database:` 블록만 수정 (같은 들여쓰기의 `server.port` 등과 구분).
181
+ *
182
+ * @param {string} targetRoot
183
+ * @param {{ host: string, port: number, user: string, password: string, name: string }} db
184
+ */
185
+ function patchApplicationYmlDatabase(targetRoot, db) {
186
+ const p = path.join(targetRoot, 'shared', 'config', 'api', 'application.yml');
187
+ if (!fs.existsSync(p)) {
188
+ console.warn('xframe: application.yml not found, skip DB patch');
189
+ return;
190
+ }
191
+ const portN = Number.isFinite(Number(db.port)) ? Number(db.port) : 3306;
192
+ const pwd = `'${yamlSingleQuotedEscape(db.password)}'`;
193
+ const lines = fs.readFileSync(p, 'utf8').split('\n');
194
+ let i = 0;
195
+ while (i < lines.length) {
196
+ if (lines[i] === 'database:') {
197
+ i += 1;
198
+ while (i < lines.length) {
199
+ const line = lines[i];
200
+ // 루트 database 직계만 (들여쓰기 2칸 + 키). 4칸 이상은 core.database 등 하위 블록.
201
+ if (line === '' || /^ [A-Za-z_#]/.test(line)) {
202
+ if (/^ host:/.test(line)) lines[i] = ` host: ${yamlScalar(db.host)}`;
203
+ else if (/^ port:/.test(line)) lines[i] = ` port: ${portN}`;
204
+ else if (/^ user:/.test(line)) lines[i] = ` user: ${yamlScalar(db.user)}`;
205
+ else if (/^ password:/.test(line)) lines[i] = ` password: ${pwd}`;
206
+ else if (/^ name:/.test(line)) lines[i] = ` name: ${yamlScalar(db.name)}`;
207
+ i += 1;
208
+ continue;
209
+ }
210
+ break;
211
+ }
212
+ break;
213
+ }
214
+ i += 1;
215
+ }
216
+ fs.writeFileSync(p, lines.join('\n'), 'utf8');
217
+ }
218
+
219
+ const DB_DEFAULTS = {
220
+ host: '127.0.0.1',
221
+ port: 3306,
222
+ user: 'root',
223
+ password: '',
224
+ name: 'app',
225
+ };
226
+
227
+ /**
228
+ * @param {{ host?: string, port?: number, user?: string, password?: string, name?: string }} fromCli
229
+ * @param {boolean} skipDbPrompt
230
+ * @param {boolean} wantDbPullFlag
231
+ * @returns {Promise<{ db: typeof DB_DEFAULTS, runDbPull: boolean }>}
232
+ */
233
+ async function collectDatabaseConfig(fromCli, skipDbPrompt, wantDbPullFlag) {
234
+ let db = { ...DB_DEFAULTS, ...fromCli };
235
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && !skipDbPrompt;
236
+ const readline = require('node:readline/promises');
237
+ const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
238
+
239
+ try {
240
+ if (interactive && rl) {
241
+ console.log('\nxframe: MySQL 연결 정보 (Enter = 괄호 안 기본값)\n');
242
+ const ask = async (label, key, def) => {
243
+ if (fromCli[key] !== undefined) return;
244
+ const hint = def === '' ? '(비워두기)' : def;
245
+ const line = (await rl.question(`${label} [${hint}]: `)).trim();
246
+ if (line !== '') {
247
+ if (key === 'port') {
248
+ const n = parseInt(line, 10);
249
+ if (Number.isFinite(n)) db[key] = n;
250
+ } else {
251
+ db[key] = line;
252
+ }
253
+ }
254
+ };
255
+ await ask('Host', 'host', DB_DEFAULTS.host);
256
+ await ask('Port', 'port', String(DB_DEFAULTS.port));
257
+ await ask('User', 'user', DB_DEFAULTS.user);
258
+ await ask('Password', 'password', DB_DEFAULTS.password);
259
+ await ask('Database name', 'name', DB_DEFAULTS.name);
260
+ } else {
261
+ db = { ...DB_DEFAULTS, ...fromCli };
262
+ }
263
+
264
+ let runDbPull = wantDbPullFlag;
265
+ if (interactive && rl && !wantDbPullFlag) {
266
+ const a = (await rl.question('\n지금 yarn/npm db:pull (스키마 역추출) 을 실행할까요? [y/N] '))
267
+ .trim()
268
+ .toLowerCase();
269
+ runDbPull = a === 'y' || a === 'yes';
270
+ }
271
+
272
+ return { db, runDbPull };
273
+ } finally {
274
+ if (rl) await rl.close();
275
+ }
97
276
  }
98
277
 
99
278
  /** yarn 우선, 없거나 실패 시 npm (부모 셸 PATH 그대로 — shell:true 쓰면 sh라 yarn/nvm PATH가 빠지는 경우가 많음) */
@@ -137,6 +316,27 @@ function installDependencies(cwd) {
137
316
  return false;
138
317
  }
139
318
 
319
+ function executeDbPull(cwd) {
320
+ console.log('\nxframe: db:pull 실행 (drizzle-kit introspect)…');
321
+ const opts = { cwd, stdio: 'inherit', env: process.env };
322
+ const y = spawnSync('yarn', ['run', 'db:pull'], opts);
323
+ if (y.status === 0) {
324
+ console.log('xframe: db:pull 완료.');
325
+ return true;
326
+ }
327
+ if (y.error && y.error.code === 'ENOENT') {
328
+ console.log('xframe: yarn 없음 → npm run db:pull 시도');
329
+ }
330
+ const n = spawnSync('npm', ['run', 'db:pull'], opts);
331
+ if (n.status === 0) {
332
+ console.log('xframe: db:pull 완료.');
333
+ return true;
334
+ }
335
+ console.error('xframe: db:pull 실패. 프로젝트 루트에서 수동으로 yarn db:pull / npm run db:pull 하세요.');
336
+ console.error('xframe: (비밀번호가 비어 있으면 drizzle-kit 이 거절할 수 있습니다. application.yml database.password 를 확인하세요.)');
337
+ return false;
338
+ }
339
+
140
340
  /** 모노레포 템플릿 tsconfig 의 @xfilecom/front-core 경로 → 생성 앱의 node_modules 기준 */
141
341
  const TEMPLATE_FRONT_CORE_TS_PATH = '../../../../front-core/src/index.ts';
142
342
  const SCAFFOLD_FRONT_CORE_TS_PATH = '../../node_modules/@xfilecom/front-core';
@@ -207,8 +407,16 @@ function readCliVersion() {
207
407
  }
208
408
  }
209
409
 
210
- function main() {
211
- const { projectDir, backendCore, frontCore, skipInstall } = parseArgs(process.argv);
410
+ async function main() {
411
+ const {
412
+ projectDir,
413
+ backendCore,
414
+ frontCore,
415
+ skipInstall,
416
+ skipDbPrompt,
417
+ dbPull: dbPullFlag,
418
+ dbFromCli,
419
+ } = parseArgs(process.argv);
212
420
 
213
421
  if (!projectDir) {
214
422
  console.error('xframe: missing <project-directory>\n');
@@ -248,6 +456,10 @@ function main() {
248
456
 
249
457
  fs.cpSync(templateRoot, targetRoot, { recursive: true });
250
458
 
459
+ const { db, runDbPull: shouldDbPull } = await collectDatabaseConfig(dbFromCli, skipDbPrompt, dbPullFlag);
460
+ patchApplicationYmlDatabase(targetRoot, db);
461
+ console.log(`xframe: wrote database.* → ${path.join('shared', 'config', 'api', 'application.yml')}`);
462
+
251
463
  const vars = {
252
464
  PACKAGE_NAME: packageName,
253
465
  SERVICE_LABEL: `${packageName}-api`,
@@ -276,13 +488,22 @@ Included:
276
488
  • shared/ — config/api·web, schema, sql, endpoint
277
489
  • .cursor/rules — @xfilecom/backend-core / front-core 사용 가이드 (.mdc)`);
278
490
 
491
+ let installOk = true;
279
492
  if (!skipInstall) {
280
- const ok = installDependencies(targetRoot);
281
- if (!ok) {
493
+ installOk = installDependencies(targetRoot);
494
+ if (!installOk) {
282
495
  process.exit(1);
283
496
  }
284
497
  }
285
498
 
499
+ if (shouldDbPull) {
500
+ if (skipInstall) {
501
+ console.warn('xframe: --no-install 이라 db:pull 을 건너뜁니다 (먼저 install 후 수동 실행).');
502
+ } else {
503
+ executeDbPull(targetRoot);
504
+ }
505
+ }
506
+
286
507
  const rel = path.relative(process.cwd(), targetRoot) || '.';
287
508
  console.log(`
288
509
  Next:
@@ -292,4 +513,7 @@ Next:
292
513
  (설치 생략: npx @xfilecom/xframe <dir> --no-install)`);
293
514
  }
294
515
 
295
- main();
516
+ main().catch((err) => {
517
+ console.error(err);
518
+ process.exit(1);
519
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfilecom/xframe",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Scaffold full-stack app: Nest + @xfilecom/backend-core, Vite/React + @xfilecom/front-core",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -17,9 +17,9 @@ http:
17
17
  database:
18
18
  host: 127.0.0.1
19
19
  port: 3306
20
- user: xfilecom
21
- password: milk1209
22
- name: usdt-market-place
20
+ user: root
21
+ password: ''
22
+ name: app
23
23
  ssl: false
24
24
  connectionLimit: 10
25
25
 
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
3
  import '@xfilecom/front-core/tokens.css';
4
+ import '../../shared/src/styles/xfc-theme.css';
4
5
  import '@xfilecom/front-core/base.css';
6
+ import '../../shared/src/styles/app.css';
5
7
  import { App } from './App';
6
8
 
7
9
  ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -1,18 +1,24 @@
1
- import { Badge, Text } from '@xfilecom/front-core';
1
+ import { useState } from 'react';
2
+ import { Badge, Button, Stack, Text } from '@xfilecom/front-core';
2
3
  import {
3
4
  Shell,
4
5
  useHealthStatus,
5
6
  FALLBACK_API_BASE_URL,
6
7
  } from '__WEB_SHARED_WORKSPACE__';
8
+ import { FrontCoreShowcase } from './FrontCoreShowcase';
7
9
 
8
10
  const apiBase = import.meta.env.VITE_API_BASE_URL || FALLBACK_API_BASE_URL;
9
11
  const title = import.meta.env.VITE_APP_TITLE || '__PACKAGE_NAME__';
10
12
 
13
+ type Tab = 'api' | 'atoms';
14
+
11
15
  export function App() {
16
+ const [tab, setTab] = useState<Tab>('atoms');
12
17
  const { text, error } = useHealthStatus(apiBase);
13
18
 
14
19
  return (
15
20
  <Shell
21
+ wide={tab === 'atoms'}
16
22
  title={title}
17
23
  subtitle={
18
24
  <>
@@ -23,15 +29,36 @@ export function App() {
23
29
  </>
24
30
  }
25
31
  >
26
- {error ? (
27
- <Text as="pre" variant="body" style={{ color: 'var(--xfc-warning)', margin: 0, whiteSpace: 'pre-wrap' }}>
28
- {error}
29
- </Text>
30
- ) : (
31
- <Text as="pre" variant="small" style={{ margin: 0, overflow: 'auto' }}>
32
- {text}
33
- </Text>
34
- )}
32
+ <Stack direction="column" gap="md" align="stretch">
33
+ <Stack direction="row" gap="sm" align="center" style={{ flexWrap: 'wrap' }}>
34
+ <Button
35
+ type="button"
36
+ variant={tab === 'atoms' ? 'primary' : 'secondary'}
37
+ onClick={() => setTab('atoms')}
38
+ >
39
+ Atoms 예시
40
+ </Button>
41
+ <Button
42
+ type="button"
43
+ variant={tab === 'api' ? 'primary' : 'secondary'}
44
+ onClick={() => setTab('api')}
45
+ >
46
+ API health
47
+ </Button>
48
+ </Stack>
49
+
50
+ {tab === 'atoms' ? (
51
+ <FrontCoreShowcase />
52
+ ) : error ? (
53
+ <Text as="pre" variant="body" style={{ color: 'var(--xfc-warning)', margin: 0, whiteSpace: 'pre-wrap' }}>
54
+ {error}
55
+ </Text>
56
+ ) : (
57
+ <Text as="pre" variant="small" style={{ margin: 0, overflow: 'auto' }}>
58
+ {text}
59
+ </Text>
60
+ )}
61
+ </Stack>
35
62
  </Shell>
36
63
  );
37
64
  }
@@ -0,0 +1,177 @@
1
+ import { useCallback, useId, useRef, useState, type ReactNode } from 'react';
2
+ import {
3
+ Badge,
4
+ Box,
5
+ Button,
6
+ Card,
7
+ Input,
8
+ InlineErrorList,
9
+ LoadingOverlay,
10
+ Stack,
11
+ Text,
12
+ Toast,
13
+ ToastList,
14
+ ToastSeverityIcon,
15
+ type InlineErrorEntry,
16
+ type ToastEntry,
17
+ type ToastSeverity,
18
+ } from '@xfilecom/front-core';
19
+
20
+ function SectionTitle({ children }: { children: ReactNode }) {
21
+ return (
22
+ <Text as="h2" variant="section" style={{ margin: 0 }}>
23
+ {children}
24
+ </Text>
25
+ );
26
+ }
27
+
28
+ export function FrontCoreShowcase() {
29
+ const uid = useId();
30
+ const toastSeq = useRef(0);
31
+ const errorSeq = useRef(0);
32
+ const [toasts, setToasts] = useState<ToastEntry[]>([]);
33
+ const [errors, setErrors] = useState<InlineErrorEntry[]>([]);
34
+ const [loading, setLoading] = useState(false);
35
+
36
+ const pushToast = useCallback(
37
+ (severity: ToastSeverity) => {
38
+ toastSeq.current += 1;
39
+ const id = `${uid}-t-${toastSeq.current}`;
40
+ setToasts((prev) => [...prev, { id, severity, message: `${severity} 메시지 예시` }]);
41
+ },
42
+ [uid],
43
+ );
44
+
45
+ const addError = useCallback(() => {
46
+ errorSeq.current += 1;
47
+ const id = `${uid}-e-${errorSeq.current}`;
48
+ setErrors((prev) => [...prev, { id, message: `샘플 에러 #${errorSeq.current}` }]);
49
+ }, [uid]);
50
+
51
+ return (
52
+ <>
53
+ <InlineErrorList errors={errors} onDismiss={(id) => setErrors((e) => e.filter((x) => x.id !== id))} />
54
+ <ToastList toasts={toasts} onDismiss={(id) => setToasts((t) => t.filter((x) => x.id !== id))} />
55
+ <LoadingOverlay active={loading} message="로딩 중…" />
56
+
57
+ <Stack direction="column" gap="lg" align="stretch">
58
+ <Card>
59
+ <Stack direction="column" gap="md" align="stretch">
60
+ <SectionTitle>Text</SectionTitle>
61
+ <Text variant="title">title</Text>
62
+ <Text variant="section">section</Text>
63
+ <Text variant="body">body — 본문 텍스트</Text>
64
+ <Text variant="muted">muted</Text>
65
+ <Text variant="small">small</Text>
66
+ <Text variant="accent">accent</Text>
67
+ </Stack>
68
+ </Card>
69
+
70
+ <Card>
71
+ <Stack direction="column" gap="md" align="stretch">
72
+ <SectionTitle>Badge</SectionTitle>
73
+ <Stack direction="row" gap="sm" align="center">
74
+ <Badge tone="neutral">neutral</Badge>
75
+ <Badge tone="accent">accent</Badge>
76
+ <Badge tone="success">success</Badge>
77
+ <Badge tone="danger">danger</Badge>
78
+ </Stack>
79
+ </Stack>
80
+ </Card>
81
+
82
+ <Card>
83
+ <Stack direction="column" gap="md" align="stretch">
84
+ <SectionTitle>Button</SectionTitle>
85
+ <Stack direction="row" gap="sm" align="center" style={{ flexWrap: 'wrap' }}>
86
+ <Button variant="primary">primary</Button>
87
+ <Button variant="secondary">secondary</Button>
88
+ <Button variant="outline">outline</Button>
89
+ <Button variant="muted">muted</Button>
90
+ <Button variant="ghost">ghost</Button>
91
+ <Button variant="primary" disabled>
92
+ disabled
93
+ </Button>
94
+ </Stack>
95
+ </Stack>
96
+ </Card>
97
+
98
+ <Card>
99
+ <Stack direction="column" gap="md" align="stretch">
100
+ <SectionTitle>Input</SectionTitle>
101
+ <label htmlFor={`${uid}-email`}>
102
+ <Text as="span" variant="labelBlock">
103
+ 이메일
104
+ </Text>
105
+ </label>
106
+ <Input id={`${uid}-email`} type="email" placeholder="you@example.com" autoComplete="email" />
107
+ <Input placeholder="비활성" disabled />
108
+ </Stack>
109
+ </Card>
110
+
111
+ <Card>
112
+ <Stack direction="column" gap="md" align="stretch">
113
+ <SectionTitle>Box · Stack · Card</SectionTitle>
114
+ <Stack direction="row" gap="md" align="stretch" style={{ flexWrap: 'wrap' }}>
115
+ <Box padding="md" style={{ border: '1px dashed var(--xfc-border-strong)', borderRadius: 'var(--xfc-radius-xs)' }}>
116
+ <Text variant="small">Box padding md</Text>
117
+ </Box>
118
+ <Card style={{ flex: '1 1 140px', minWidth: 0 }}>
119
+ <Text variant="small">중첩 Card</Text>
120
+ </Card>
121
+ </Stack>
122
+ </Stack>
123
+ </Card>
124
+
125
+ <Card>
126
+ <Stack direction="column" gap="md" align="stretch">
127
+ <SectionTitle>ToastSeverityIcon</SectionTitle>
128
+ <Stack direction="row" gap="lg" align="center" style={{ color: 'var(--xfc-fg)' }}>
129
+ <ToastSeverityIcon severity="info" />
130
+ <ToastSeverityIcon severity="success" />
131
+ <ToastSeverityIcon severity="warn" />
132
+ <ToastSeverityIcon severity="error" />
133
+ </Stack>
134
+ </Stack>
135
+ </Card>
136
+
137
+ <Card>
138
+ <Stack direction="column" gap="md" align="stretch">
139
+ <SectionTitle>Toast (단일)</SectionTitle>
140
+ <Toast severity="info" message="인라인 Toast 한 줄" onDismiss={() => {}} />
141
+ </Stack>
142
+ </Card>
143
+
144
+ <Card>
145
+ <Stack direction="column" gap="md" align="stretch">
146
+ <SectionTitle>ToastList · InlineErrorList · LoadingOverlay</SectionTitle>
147
+ <Text variant="small" style={{ color: 'var(--xfc-fg-muted)' }}>
148
+ ToastList / InlineErrorList / LoadingOverlay 는 화면 고정 레이어입니다. 아래 버튼으로 띄워 보세요.
149
+ </Text>
150
+ <Stack direction="row" gap="sm" align="center" style={{ flexWrap: 'wrap' }}>
151
+ <Button type="button" variant="secondary" onClick={() => pushToast('info')}>
152
+ toast info
153
+ </Button>
154
+ <Button type="button" variant="secondary" onClick={() => pushToast('success')}>
155
+ toast success
156
+ </Button>
157
+ <Button type="button" variant="secondary" onClick={() => pushToast('warn')}>
158
+ toast warn
159
+ </Button>
160
+ <Button type="button" variant="secondary" onClick={() => pushToast('error')}>
161
+ toast error
162
+ </Button>
163
+ </Stack>
164
+ <Stack direction="row" gap="sm" align="center" style={{ flexWrap: 'wrap' }}>
165
+ <Button type="button" variant="outline" onClick={addError}>
166
+ 에러 배너 추가
167
+ </Button>
168
+ <Button type="button" variant="outline" onClick={() => setLoading((v) => !v)}>
169
+ 로딩 오버레이 {loading ? '끄기' : '켜기'}
170
+ </Button>
171
+ </Stack>
172
+ </Stack>
173
+ </Card>
174
+ </Stack>
175
+ </>
176
+ );
177
+ }
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
3
  import '@xfilecom/front-core/tokens.css';
4
+ import '../../shared/src/styles/xfc-theme.css';
4
5
  import '@xfilecom/front-core/base.css';
6
+ import '../../shared/src/styles/app.css';
5
7
  import { App } from './App';
6
8
 
7
9
  ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -10,7 +10,9 @@
10
10
  "types": "./src/index.ts",
11
11
  "import": "./src/index.ts",
12
12
  "default": "./src/index.ts"
13
- }
13
+ },
14
+ "./styles/xfc-theme.css": "./src/styles/xfc-theme.css",
15
+ "./styles/app.css": "./src/styles/app.css"
14
16
  },
15
17
  "scripts": {
16
18
  "build": "tsc -p tsconfig.build.json"
@@ -1,16 +1,17 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { Card, Stack, Text } from '@xfilecom/front-core';
3
- import '../styles/app.css';
4
3
 
5
4
  export type ShellProps = {
6
5
  title: string;
7
6
  subtitle?: ReactNode;
8
7
  children?: ReactNode;
8
+ /** true면 폭 제한(480) 없음 — atoms 갤러리 등 */
9
+ wide?: boolean;
9
10
  };
10
11
 
11
- export function Shell({ title, subtitle, children }: ShellProps) {
12
+ export function Shell({ title, subtitle, children, wide = false }: ShellProps) {
12
13
  return (
13
- <div className="xfc-page xfc-page--narrow">
14
+ <div className={wide ? 'xfc-page' : 'xfc-page xfc-page--narrow'}>
14
15
  <header className="xfc-hero">
15
16
  <Text as="h1" variant="appbar">
16
17
  {title}
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * 앱 레이아웃 — `html/html` 모바일 프레임(폭 480)에 맞춤.
3
- * 토큰·atoms는 @xfilecom/front-core.
3
+ *
4
+ * 엔트리에서 @xfilecom/front-core/base.css 다음에 로드하세요.
5
+ * 이 파일은 tokens / xfc-theme / atoms 이후이므로
6
+ * - 레이아웃(.xfc-page 등)과
7
+ * - 필요 시 atoms(.xfc-btn, .xfc-toast 등) 덮어쓰기
8
+ * 를 같은 파일에 둘 수 있습니다.
4
9
  */
5
10
 
6
11
  .xfc-page {
@@ -61,3 +66,10 @@
61
66
  background: var(--xfc-bg);
62
67
  border-bottom: 1px solid var(--xfc-border-header);
63
68
  }
69
+
70
+ /* —— optional: front-core atoms 오버라이드 (base.css 이후 로드됨) —— */
71
+ /*
72
+ .xfc-btn--primary {
73
+ border-radius: var(--xfc-radius-md);
74
+ }
75
+ */
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 앱·브랜드별 디자인 토큰 덮어쓰기.
3
+ *
4
+ * 엔트리(main.tsx)에서 반드시 다음 순서로 넣으세요:
5
+ * 1. @xfilecom/front-core/tokens.css
6
+ * 2. 이 파일 (xfc-theme.css)
7
+ * 3. @xfilecom/front-core/base.css ← atoms 가 var(--xfc-*) 를 참조
8
+ * 4. app.css ← 레이아웃 + atoms(.xfc-*) 클래스 오버라이드
9
+ *
10
+ * 여기서 :root 의 --xfc-* 를 바꾸면 Button·Input·Toast 등 front-core atoms 스타일에 그대로 반영됩니다.
11
+ */
12
+
13
+ /* 예시 (필요할 때 주석 해제)
14
+ :root {
15
+ --xfc-accent: #2a5bff;
16
+ --xfc-accent-hover: #2248e6;
17
+ }
18
+ */