@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 +244 -20
- package/package.json +1 -1
- package/template/shared/config/api/application.yml +3 -3
- package/template/web/admin/src/main.tsx +2 -0
- package/template/web/client/src/App.tsx +37 -10
- package/template/web/client/src/FrontCoreShowcase.tsx +177 -0
- package/template/web/client/src/main.tsx +2 -0
- package/template/web/shared/package.json +3 -1
- package/template/web/shared/src/components/Shell.tsx +4 -3
- package/template/web/shared/src/styles/app.css +13 -1
- package/template/web/shared/src/styles/xfc-theme.css +18 -0
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 --
|
|
44
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
281
|
-
if (!
|
|
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,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 {
|
|
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
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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=
|
|
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
|
-
*
|
|
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
|
+
*/
|