create-sonamu 0.0.1 → 0.0.2
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/README.md +365 -2
- package/index.js +632 -0
- package/package.json +30 -20
- package/template/src/README.md +274 -0
- package/template/src/packages/api/.swcrc +18 -0
- package/template/src/packages/api/custom-sequencer.ts +23 -0
- package/template/src/packages/api/database/docker-compose.yml +19 -0
- package/template/src/packages/api/database/fixtures/init.sh +15 -0
- package/template/src/packages/api/database/scripts/dump.sh +62 -0
- package/template/src/packages/api/database/scripts/seed.sh +60 -0
- package/template/src/packages/api/package.json +55 -0
- package/template/src/packages/api/src/application/.gitkeep +1 -0
- package/template/src/packages/api/src/i18n/en.ts +59 -0
- package/template/src/packages/api/src/i18n/ko.ts +57 -0
- package/template/src/packages/api/src/index.ts +6 -0
- package/template/src/packages/api/src/migrations/.gitkeep +1 -0
- package/template/src/packages/api/src/sonamu.config.ts +162 -0
- package/template/src/packages/api/src/testing/fixture.ts +6 -0
- package/template/src/packages/api/src/testing/global.ts +6 -0
- package/template/src/packages/api/src/testing/setup-mocks.ts +44 -0
- package/template/src/packages/api/src/typings/fastify.d.ts +7 -0
- package/template/src/packages/api/src/typings/sonamu.d.ts +19 -0
- package/template/src/packages/api/src/utils/subset-loaders.ts +11 -0
- package/template/src/packages/api/tsconfig.json +60 -0
- package/template/src/packages/api/tsconfig.schemas.json +5 -0
- package/template/src/packages/api/tsconfig.types.json +5 -0
- package/template/src/packages/api/vitest.config.ts +36 -0
- package/template/src/packages/web/.sonamu.env +2 -0
- package/template/src/{web → packages/web}/index.html +3 -3
- package/template/src/packages/web/package.json +49 -0
- package/template/src/packages/web/src/App.tsx +17 -0
- package/template/src/packages/web/src/admin-common/ApiLogViewer.tsx +285 -0
- package/template/src/packages/web/src/admin-common/CommonModal.tsx +91 -0
- package/template/src/packages/web/src/contexts/sonamu-provider.tsx +41 -0
- package/template/src/packages/web/src/entry-client.tsx +72 -0
- package/template/src/packages/web/src/entry-server.generated.tsx +58 -0
- package/template/src/packages/web/src/i18n/en.ts +63 -0
- package/template/src/packages/web/src/i18n/ko.ts +61 -0
- package/template/src/packages/web/src/routeTree.gen.ts +27 -0
- package/template/src/packages/web/src/routes/__root.tsx +44 -0
- package/template/src/packages/web/src/routes/index.tsx +14 -0
- package/template/src/packages/web/src/styles/tailwind.css +5 -0
- package/template/src/packages/web/src/vite-env.d.ts +2 -0
- package/template/src/packages/web/tailwind.config.ts +8 -0
- package/template/src/{web → packages/web}/tsconfig.json +5 -3
- package/template/src/packages/web/vite.config.ts +51 -0
- package/template/src/api/README.md +0 -3
- package/template/src/api/database/docker-compose.yml +0 -17
- package/template/src/api/package.json +0 -39
- package/template/src/api/sonamu.config.json +0 -11
- package/template/src/api/src/configs/db.ts +0 -25
- package/template/src/api/src/index.ts +0 -36
- package/template/src/api/src/testing/bootstrap.ts +0 -20
- package/template/src/api/src/testing/fixture.ts +0 -18
- package/template/src/api/src/testing/global.ts +0 -7
- package/template/src/api/src/typings/sonamu.d.ts +0 -5
- package/template/src/api/tsconfig.json +0 -115
- package/template/src/api/vite.config.mts +0 -15
- package/template/src/web/package.json +0 -40
- package/template/src/web/public/vite.svg +0 -1
- package/template/src/web/src/App.css +0 -34
- package/template/src/web/src/App.tsx +0 -15
- package/template/src/web/src/assets/react.svg +0 -1
- package/template/src/web/src/index.css +0 -76
- package/template/src/web/src/main.tsx +0 -30
- package/template/src/web/src/pages/index.tsx +0 -11
- package/template/src/web/src/vite-env.d.ts +0 -1
- package/template/src/web/vite.config.ts +0 -20
- /package/template/src/{web/src/services → packages/api/database/dumps}/.gitkeep +0 -0
- /package/template/src/{api/database/scripts/init.sql → packages/web/src/services/.gitkeep} +0 -0
- /package/template/src/{web → packages/web}/tsconfig.node.json +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/** biome-ignore-all lint/suspicious/noExplicitAny: axios 사용 시 타입 추론 어려우므로 허용 */
|
|
2
|
+
|
|
3
|
+
import { Button, Card, CardContent, CardHeader } from "@sonamu-kit/react-components/components";
|
|
4
|
+
import axios from "axios";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import TrashIcon from "~icons/lucide/trash-2";
|
|
7
|
+
|
|
8
|
+
type ApiLog = {
|
|
9
|
+
id: string;
|
|
10
|
+
method: string;
|
|
11
|
+
url: string;
|
|
12
|
+
requestHeaders?: Record<string, any>;
|
|
13
|
+
requestBody?: any;
|
|
14
|
+
requestQuery?: Record<string, any>;
|
|
15
|
+
responseStatus?: number;
|
|
16
|
+
responseHeaders?: Record<string, any>;
|
|
17
|
+
responseBody?: any;
|
|
18
|
+
duration?: number;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function ApiLogViewer({ bodyOnly = false }: { bodyOnly?: boolean }) {
|
|
23
|
+
const [apiLogs, setApiLogs] = useState<ApiLog[]>([]);
|
|
24
|
+
const requestStartTimes = useRef<Map<string, number>>(new Map());
|
|
25
|
+
|
|
26
|
+
// Axios interceptor 설정
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const requestInterceptor = axios.interceptors.request.use(
|
|
29
|
+
(config) => {
|
|
30
|
+
const logId = `${Date.now()}-${Math.random()}`;
|
|
31
|
+
const startTime = Date.now();
|
|
32
|
+
requestStartTimes.current.set(logId, startTime);
|
|
33
|
+
|
|
34
|
+
const log: ApiLog = {
|
|
35
|
+
id: logId,
|
|
36
|
+
method: config.method?.toUpperCase() || "GET",
|
|
37
|
+
url: config.url || "",
|
|
38
|
+
requestHeaders: config.headers as Record<string, any>,
|
|
39
|
+
requestBody: config.data,
|
|
40
|
+
requestQuery: config.params,
|
|
41
|
+
timestamp: startTime,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// FormData는 표시 불가
|
|
45
|
+
if (config.data instanceof FormData) {
|
|
46
|
+
log.requestBody = "[FormData]";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setApiLogs((prev) => [log, ...prev]);
|
|
50
|
+
(config as any).__logId = logId;
|
|
51
|
+
|
|
52
|
+
return config;
|
|
53
|
+
},
|
|
54
|
+
(error) => {
|
|
55
|
+
return Promise.reject(error);
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const responseInterceptor = axios.interceptors.response.use(
|
|
60
|
+
(response) => {
|
|
61
|
+
const logId = (response.config as any).__logId;
|
|
62
|
+
const startTime = requestStartTimes.current.get(logId);
|
|
63
|
+
const duration = startTime ? Date.now() - startTime : undefined;
|
|
64
|
+
requestStartTimes.current.delete(logId);
|
|
65
|
+
|
|
66
|
+
setApiLogs((prev) =>
|
|
67
|
+
prev.map((log) =>
|
|
68
|
+
log.id === logId
|
|
69
|
+
? {
|
|
70
|
+
...log,
|
|
71
|
+
responseStatus: response.status,
|
|
72
|
+
responseHeaders: response.headers as Record<string, any>,
|
|
73
|
+
responseBody: response.data,
|
|
74
|
+
duration,
|
|
75
|
+
}
|
|
76
|
+
: log,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return response;
|
|
81
|
+
},
|
|
82
|
+
(error) => {
|
|
83
|
+
const logId = (error.config as any)?.__logId;
|
|
84
|
+
const startTime = logId ? requestStartTimes.current.get(logId) : undefined;
|
|
85
|
+
const duration = startTime ? Date.now() - startTime : undefined;
|
|
86
|
+
if (logId) {
|
|
87
|
+
requestStartTimes.current.delete(logId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (logId) {
|
|
91
|
+
setApiLogs((prev) =>
|
|
92
|
+
prev.map((log) =>
|
|
93
|
+
log.id === logId
|
|
94
|
+
? {
|
|
95
|
+
...log,
|
|
96
|
+
responseStatus: error.response?.status,
|
|
97
|
+
responseHeaders: error.response?.headers,
|
|
98
|
+
responseBody: error.response?.data,
|
|
99
|
+
duration,
|
|
100
|
+
}
|
|
101
|
+
: log,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return Promise.reject(error);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
axios.interceptors.request.eject(requestInterceptor);
|
|
112
|
+
axios.interceptors.response.eject(responseInterceptor);
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Card className="border-purple-200 bg-purple-50/50 shadow-sm">
|
|
118
|
+
<CardHeader className="pb-2">
|
|
119
|
+
<div className="flex justify-between items-center">
|
|
120
|
+
<div className="text-sm font-semibold text-purple-700">API 로그</div>
|
|
121
|
+
<Button
|
|
122
|
+
size="sm"
|
|
123
|
+
onClick={() => setApiLogs([])}
|
|
124
|
+
disabled={apiLogs.length === 0}
|
|
125
|
+
icon={<TrashIcon />}
|
|
126
|
+
>
|
|
127
|
+
로그 지우기
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
</CardHeader>
|
|
131
|
+
<CardContent className="space-y-2">
|
|
132
|
+
<div
|
|
133
|
+
style={{
|
|
134
|
+
maxHeight: "400px",
|
|
135
|
+
overflowY: "auto",
|
|
136
|
+
fontFamily: "monospace",
|
|
137
|
+
fontSize: "12px",
|
|
138
|
+
backgroundColor: "#1e1e1e",
|
|
139
|
+
color: "#d4d4d4",
|
|
140
|
+
padding: "1em",
|
|
141
|
+
borderRadius: "4px",
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
{apiLogs.length === 0 ? (
|
|
145
|
+
<div style={{ color: "#808080" }}>API 호출이 없습니다.</div>
|
|
146
|
+
) : (
|
|
147
|
+
apiLogs.map((log) => (
|
|
148
|
+
<div
|
|
149
|
+
key={log.id}
|
|
150
|
+
style={{
|
|
151
|
+
marginBottom: "2em",
|
|
152
|
+
borderBottom: "1px solid #3e3e3e",
|
|
153
|
+
paddingBottom: "1em",
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<div style={{ marginBottom: "0.5em" }}>
|
|
157
|
+
<span style={{ color: "#569cd6", fontWeight: "bold" }}>[{log.method}]</span>{" "}
|
|
158
|
+
<span style={{ color: "#4ec9b0" }}>{log.url}</span>
|
|
159
|
+
{log.duration !== undefined && (
|
|
160
|
+
<span style={{ color: "#808080", marginLeft: "1em" }}>({log.duration}ms)</span>
|
|
161
|
+
)}
|
|
162
|
+
{log.responseStatus !== undefined && (
|
|
163
|
+
<span
|
|
164
|
+
style={{
|
|
165
|
+
color:
|
|
166
|
+
log.responseStatus >= 200 && log.responseStatus < 300
|
|
167
|
+
? "#6a9955"
|
|
168
|
+
: log.responseStatus >= 400
|
|
169
|
+
? "#f48771"
|
|
170
|
+
: "#dcdcaa",
|
|
171
|
+
marginLeft: "1em",
|
|
172
|
+
fontWeight: "bold",
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
Status: {log.responseStatus}
|
|
176
|
+
</span>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{!bodyOnly && log.requestHeaders && Object.keys(log.requestHeaders).length > 0 && (
|
|
181
|
+
<div style={{ marginBottom: "0.5em" }}>
|
|
182
|
+
<div style={{ color: "#9cdcfe", marginBottom: "0.25em" }}>Request Headers:</div>
|
|
183
|
+
<pre
|
|
184
|
+
style={{
|
|
185
|
+
margin: 0,
|
|
186
|
+
padding: "0.5em",
|
|
187
|
+
backgroundColor: "#252526",
|
|
188
|
+
borderRadius: "4px",
|
|
189
|
+
overflowX: "auto",
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
{JSON.stringify(log.requestHeaders, null, 2)}
|
|
193
|
+
</pre>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{!bodyOnly && log.requestQuery && Object.keys(log.requestQuery).length > 0 && (
|
|
198
|
+
<div style={{ marginBottom: "0.5em" }}>
|
|
199
|
+
<div style={{ color: "#9cdcfe", marginBottom: "0.25em" }}>Query Params:</div>
|
|
200
|
+
<pre
|
|
201
|
+
style={{
|
|
202
|
+
margin: 0,
|
|
203
|
+
padding: "0.5em",
|
|
204
|
+
backgroundColor: "#252526",
|
|
205
|
+
borderRadius: "4px",
|
|
206
|
+
overflowX: "auto",
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
{JSON.stringify(log.requestQuery, null, 2)}
|
|
210
|
+
</pre>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{!bodyOnly && log.requestBody !== undefined && (
|
|
215
|
+
<div style={{ marginBottom: "0.5em" }}>
|
|
216
|
+
<div style={{ color: "#9cdcfe", marginBottom: "0.25em" }}>Request Body:</div>
|
|
217
|
+
<pre
|
|
218
|
+
style={{
|
|
219
|
+
margin: 0,
|
|
220
|
+
padding: "0.5em",
|
|
221
|
+
backgroundColor: "#252526",
|
|
222
|
+
borderRadius: "4px",
|
|
223
|
+
overflowX: "auto",
|
|
224
|
+
whiteSpace: "pre-wrap",
|
|
225
|
+
wordBreak: "break-all",
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
{typeof log.requestBody === "string"
|
|
229
|
+
? log.requestBody
|
|
230
|
+
: JSON.stringify(log.requestBody, null, 2)}
|
|
231
|
+
</pre>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{!bodyOnly &&
|
|
236
|
+
log.responseHeaders &&
|
|
237
|
+
Object.keys(log.responseHeaders).length > 0 && (
|
|
238
|
+
<div style={{ marginBottom: "0.5em" }}>
|
|
239
|
+
<div style={{ color: "#9cdcfe", marginBottom: "0.25em" }}>
|
|
240
|
+
Response Headers:
|
|
241
|
+
</div>
|
|
242
|
+
<pre
|
|
243
|
+
style={{
|
|
244
|
+
margin: 0,
|
|
245
|
+
padding: "0.5em",
|
|
246
|
+
backgroundColor: "#252526",
|
|
247
|
+
borderRadius: "4px",
|
|
248
|
+
overflowX: "auto",
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
{JSON.stringify(log.responseHeaders, null, 2)}
|
|
252
|
+
</pre>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{log.responseBody !== undefined && (
|
|
257
|
+
<div style={{ marginBottom: "0.5em" }}>
|
|
258
|
+
<div style={{ color: "#9cdcfe", marginBottom: "0.25em" }}>Response Body:</div>
|
|
259
|
+
<pre
|
|
260
|
+
style={{
|
|
261
|
+
margin: 0,
|
|
262
|
+
padding: "0.5em",
|
|
263
|
+
backgroundColor: "#252526",
|
|
264
|
+
borderRadius: "4px",
|
|
265
|
+
overflowX: "auto",
|
|
266
|
+
whiteSpace: "pre-wrap",
|
|
267
|
+
wordBreak: "break-all",
|
|
268
|
+
maxHeight: "200px",
|
|
269
|
+
overflowY: "auto",
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{typeof log.responseBody === "string"
|
|
273
|
+
? log.responseBody
|
|
274
|
+
: JSON.stringify(log.responseBody, null, 2)}
|
|
275
|
+
</pre>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
))
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
</CardContent>
|
|
283
|
+
</Card>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Dialog, DialogContent, DialogDescription } from "@sonamu-kit/react-components/components";
|
|
2
|
+
import { atom, useAtom } from "jotai";
|
|
3
|
+
import type React from "react";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
type ExtendedDialogProps = {
|
|
7
|
+
onCompleted?: (data?: unknown) => void;
|
|
8
|
+
onControlledOpen?: () => void;
|
|
9
|
+
onControlledClose?: () => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const commonModalAtom = atom<
|
|
14
|
+
{
|
|
15
|
+
open: boolean;
|
|
16
|
+
reactNode: React.ReactNode | null;
|
|
17
|
+
} & ExtendedDialogProps
|
|
18
|
+
>({
|
|
19
|
+
open: false,
|
|
20
|
+
reactNode: null,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type CommonModalProps = {};
|
|
24
|
+
|
|
25
|
+
export function CommonModal({}: CommonModalProps) {
|
|
26
|
+
const [atomValue, setAtomValue] = useAtom(commonModalAtom);
|
|
27
|
+
const { open, reactNode, onControlledOpen, onControlledClose, className } = atomValue;
|
|
28
|
+
|
|
29
|
+
const closeAndClear = () => {
|
|
30
|
+
if (onControlledClose) {
|
|
31
|
+
onControlledClose();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setAtomValue({
|
|
35
|
+
open: false,
|
|
36
|
+
reactNode: null,
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (open && onControlledOpen) {
|
|
42
|
+
onControlledOpen();
|
|
43
|
+
}
|
|
44
|
+
}, [open, onControlledOpen]);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && closeAndClear()}>
|
|
48
|
+
<DialogContent className={className}>
|
|
49
|
+
<DialogDescription asChild>{reactNode}</DialogDescription>
|
|
50
|
+
</DialogContent>
|
|
51
|
+
</Dialog>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useCommonModal() {
|
|
56
|
+
const [atomValue, setAtomValue] = useAtom(commonModalAtom);
|
|
57
|
+
const { open, reactNode, onCompleted, onControlledClose } = atomValue;
|
|
58
|
+
|
|
59
|
+
const openModal = (reactNode: React.ReactNode, props?: ExtendedDialogProps) => {
|
|
60
|
+
setAtomValue({
|
|
61
|
+
open: true,
|
|
62
|
+
reactNode,
|
|
63
|
+
...props,
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const closeModal = () => {
|
|
68
|
+
setAtomValue({
|
|
69
|
+
open: false,
|
|
70
|
+
reactNode: null,
|
|
71
|
+
});
|
|
72
|
+
if (onControlledClose) {
|
|
73
|
+
onControlledClose();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const doneModal = (data?: unknown) => {
|
|
78
|
+
closeModal();
|
|
79
|
+
if (onCompleted) {
|
|
80
|
+
onCompleted(data);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
open,
|
|
86
|
+
reactNode,
|
|
87
|
+
openModal,
|
|
88
|
+
closeModal,
|
|
89
|
+
doneModal,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { SonamuProvider, type SonamuContextValue } from "@sonamu-kit/react-components";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
// Temporary type until sd.generated is created
|
|
5
|
+
type EmptyDictionary = Record<string, never>;
|
|
6
|
+
|
|
7
|
+
export function createSonamuConfig(): SonamuContextValue<EmptyDictionary> {
|
|
8
|
+
// Auth configuration
|
|
9
|
+
const auth_config = {
|
|
10
|
+
user: null,
|
|
11
|
+
loading: false,
|
|
12
|
+
login: async (_loginParams: any) => {
|
|
13
|
+
// TODO: Implement login logic
|
|
14
|
+
console.log("Login not implemented yet");
|
|
15
|
+
},
|
|
16
|
+
logout: async () => {
|
|
17
|
+
// TODO: Implement logout logic
|
|
18
|
+
console.log("Logout not implemented yet");
|
|
19
|
+
},
|
|
20
|
+
refetch: async () => {
|
|
21
|
+
// TODO: Implement refetch logic
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Uploader configuration
|
|
26
|
+
const uploader_config = async (_files: File[]) => {
|
|
27
|
+
// TODO: Implement file upload logic
|
|
28
|
+
console.log("File upload not implemented yet");
|
|
29
|
+
return [];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// SD configuration - returns key as-is until i18n is set up
|
|
33
|
+
const sd_config = (key: string): any => key;
|
|
34
|
+
|
|
35
|
+
return { auth: auth_config, uploader: uploader_config, SD: sd_config };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SonamuProviderWrapper({ children }: { children: ReactNode }) {
|
|
39
|
+
const sonamuConfig = createSonamuConfig();
|
|
40
|
+
return <SonamuProvider<EmptyDictionary> {...sonamuConfig}>{children}</SonamuProvider>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { hydrate, QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
|
3
|
+
import ReactDOM from "react-dom/client";
|
|
4
|
+
import { routeTree } from "./routeTree.gen";
|
|
5
|
+
import "./styles/tailwind.css";
|
|
6
|
+
|
|
7
|
+
// SSR data types
|
|
8
|
+
declare global {
|
|
9
|
+
interface Window {
|
|
10
|
+
// biome-ignore lint/suspicious/noExplicitAny: SSR data needs to be any type
|
|
11
|
+
__SONAMU_SSR__?: any;
|
|
12
|
+
__SONAMU_SSR_CONFIG__?: {
|
|
13
|
+
disableHydrate?: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Date reviver function for JSON.parse
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: reviver needs to handle any type
|
|
20
|
+
function dateReviver(_key: string, value: any) {
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
23
|
+
if (datePattern.test(value)) {
|
|
24
|
+
return new Date(value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Create QueryClient
|
|
31
|
+
const queryClient = new QueryClient({
|
|
32
|
+
defaultOptions: {
|
|
33
|
+
queries: {
|
|
34
|
+
staleTime: 5000,
|
|
35
|
+
retry: false,
|
|
36
|
+
refetchOnMount: true,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Restore SSR data
|
|
42
|
+
const dehydratedState = window.__SONAMU_SSR__
|
|
43
|
+
? JSON.parse(JSON.stringify(window.__SONAMU_SSR__), dateReviver)
|
|
44
|
+
: undefined;
|
|
45
|
+
if (dehydratedState) {
|
|
46
|
+
hydrate(queryClient, dehydratedState);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check SSR Config
|
|
50
|
+
const ssrConfig = window.__SONAMU_SSR_CONFIG__;
|
|
51
|
+
|
|
52
|
+
// Create Router
|
|
53
|
+
const router = createRouter({
|
|
54
|
+
routeTree,
|
|
55
|
+
context: { queryClient },
|
|
56
|
+
defaultPreload: "intent",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
declare module "@tanstack/react-router" {
|
|
60
|
+
interface Register {
|
|
61
|
+
router: typeof router;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Render the app
|
|
66
|
+
const rootElement = document.getElementById("root")!;
|
|
67
|
+
if (!rootElement.innerHTML || ssrConfig?.disableHydrate) {
|
|
68
|
+
const root = ReactDOM.createRoot(rootElement);
|
|
69
|
+
root.render(<RouterProvider router={router} />);
|
|
70
|
+
} else {
|
|
71
|
+
ReactDOM.hydrateRoot(rootElement, <RouterProvider router={router} />);
|
|
72
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
|
|
3
|
+
import { Suspense } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { routeTree } from "./routeTree.gen";
|
|
6
|
+
|
|
7
|
+
export type PreloadedData = {
|
|
8
|
+
queryKey: any[];
|
|
9
|
+
data: any;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function render(url: string, preloadedData: PreloadedData[] = []) {
|
|
13
|
+
// QueryClient 생성
|
|
14
|
+
const queryClient = new QueryClient({
|
|
15
|
+
defaultOptions: {
|
|
16
|
+
queries: {
|
|
17
|
+
staleTime: 5000,
|
|
18
|
+
retry: false,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Preloaded 데이터를 queryClient에 직접 주입
|
|
24
|
+
for (const { queryKey, data } of preloadedData) {
|
|
25
|
+
queryClient.setQueryData(queryKey, data);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Dehydrate
|
|
29
|
+
const dehydratedState = dehydrate(queryClient);
|
|
30
|
+
|
|
31
|
+
// SSR용 메모리 히스토리 생성
|
|
32
|
+
const memoryHistory = createMemoryHistory({
|
|
33
|
+
initialEntries: [url],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Router 생성 (SSR 모드)
|
|
37
|
+
const router = createRouter({
|
|
38
|
+
routeTree,
|
|
39
|
+
context: { queryClient },
|
|
40
|
+
history: memoryHistory,
|
|
41
|
+
defaultPreload: "intent",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// 라우터 초기화: SSR에서 반드시 await router.load() 호출 필요
|
|
45
|
+
await router.load();
|
|
46
|
+
|
|
47
|
+
// RouterProvider만 렌더링 (Suspense로 래핑 - hydration mismatch 방지)
|
|
48
|
+
const appHtml = renderToString(
|
|
49
|
+
<Suspense fallback={null}>
|
|
50
|
+
<RouterProvider router={router} />
|
|
51
|
+
</Suspense>,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
html: appHtml,
|
|
56
|
+
dehydratedState,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Simple plural helper - will be replaced by sonamu.shared after sync
|
|
2
|
+
function plural(count: number, singular: string, _plural: string): string {
|
|
3
|
+
return count === 1 ? singular : _plural;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Project EN Dictionary
|
|
8
|
+
*/
|
|
9
|
+
export default {
|
|
10
|
+
"common.all": "All",
|
|
11
|
+
"common.backToList": "Back to List",
|
|
12
|
+
"common.cancel": "Cancel",
|
|
13
|
+
"common.close": "Close",
|
|
14
|
+
"common.confirm": "Confirm",
|
|
15
|
+
"common.create": "Create",
|
|
16
|
+
"common.createdAt": "Created At",
|
|
17
|
+
"common.delete": "Delete",
|
|
18
|
+
"common.edit": "Edit",
|
|
19
|
+
"common.login": "Login",
|
|
20
|
+
"common.logout": "Logout",
|
|
21
|
+
"common.manage": "Manage",
|
|
22
|
+
"common.results": (count: number) =>
|
|
23
|
+
plural(count, `${count} result`, `${count} results`),
|
|
24
|
+
"common.save": "Save",
|
|
25
|
+
"common.search": "Search",
|
|
26
|
+
"common.searchPlaceholder": "Search...",
|
|
27
|
+
"common.searchType": "Search Type",
|
|
28
|
+
"common.sort": "Sort",
|
|
29
|
+
"confirm.delete": "Are you sure you want to delete?",
|
|
30
|
+
"confirm.save": "Do you want to save?",
|
|
31
|
+
"dashboard.title": "Dashboard",
|
|
32
|
+
"dashboard.welcome": "Welcome!",
|
|
33
|
+
"delete.confirm.description":
|
|
34
|
+
"This action cannot be undone. This will permanently delete this item.",
|
|
35
|
+
"delete.confirm.title": "Are you sure?",
|
|
36
|
+
"entity.create": (name: string) => `Create ${name}`,
|
|
37
|
+
"entity.edit": (name: string, id: number) => `Edit ${name} (#${id})`,
|
|
38
|
+
"entity.list": (name: string) => `${name} List`,
|
|
39
|
+
"entity.listManage": (name: string) => `Manage ${name} List`,
|
|
40
|
+
"error.badRequest": "Bad Request",
|
|
41
|
+
"error.duplicateRow": "Duplicate data",
|
|
42
|
+
"error.forbidden": "Permission denied",
|
|
43
|
+
"error.internalServerError": "Internal server error",
|
|
44
|
+
"error.notFound": "Not found",
|
|
45
|
+
"error.unauthorized": "Authentication required",
|
|
46
|
+
notFound: (name: string, id: number) => `${name} ID ${id} not found`,
|
|
47
|
+
"validation.email": "Invalid email format",
|
|
48
|
+
"validation.maxLength": (field: string, max: number) =>
|
|
49
|
+
`${field} must be at most ${max} characters`,
|
|
50
|
+
"validation.minLength": (field: string, min: number) =>
|
|
51
|
+
`${field} must be at least ${min} characters`,
|
|
52
|
+
"validation.required": (field: string) => `${field} is required`,
|
|
53
|
+
"validation.url": "Invalid URL format",
|
|
54
|
+
// components
|
|
55
|
+
"component.asyncSelect.loading": "Loading...",
|
|
56
|
+
"component.asyncSelect.noOptions": "No options",
|
|
57
|
+
"component.asyncSelect.noResults": "No results",
|
|
58
|
+
"component.asyncSelect.selectPlaceholder": "Select",
|
|
59
|
+
"component.datePicker.pickDate": "Pick a date",
|
|
60
|
+
"component.datePicker.placeholder": "Pick a date",
|
|
61
|
+
"component.fileInput.browseFiles": "Browse Files",
|
|
62
|
+
"component.fileInput.dropZone": "Drag and drop files here or click to upload",
|
|
63
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Simple josa helper - will be replaced by sonamu.shared after sync
|
|
2
|
+
function josa(word: string, _type: string): string {
|
|
3
|
+
return word;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Project KO Dictionary
|
|
8
|
+
*/
|
|
9
|
+
export default {
|
|
10
|
+
"common.all": "전체",
|
|
11
|
+
"common.backToList": "목록으로",
|
|
12
|
+
"common.cancel": "취소",
|
|
13
|
+
"common.close": "닫기",
|
|
14
|
+
"common.confirm": "확인",
|
|
15
|
+
"common.create": "생성",
|
|
16
|
+
"common.createdAt": "등록",
|
|
17
|
+
"common.delete": "삭제",
|
|
18
|
+
"common.edit": "수정",
|
|
19
|
+
"common.login": "로그인",
|
|
20
|
+
"common.logout": "로그아웃",
|
|
21
|
+
"common.manage": "관리",
|
|
22
|
+
"common.results": (count: number) => `${count}개 결과`,
|
|
23
|
+
"common.save": "저장",
|
|
24
|
+
"common.search": "검색",
|
|
25
|
+
"common.searchPlaceholder": "검색...",
|
|
26
|
+
"common.searchType": "검색 유형",
|
|
27
|
+
"common.sort": "정렬",
|
|
28
|
+
"confirm.delete": "정말 삭제하시겠습니까?",
|
|
29
|
+
"confirm.save": "저장하시겠습니까?",
|
|
30
|
+
"dashboard.title": "대시보드",
|
|
31
|
+
"dashboard.welcome": "환영합니다!",
|
|
32
|
+
"delete.confirm.description": "이 작업은 취소할 수 없습니다. 항목이 영구적으로 삭제됩니다.",
|
|
33
|
+
"delete.confirm.title": "정말 삭제하시겠습니까?",
|
|
34
|
+
"entity.create": (name: string) => `${name} 생성`,
|
|
35
|
+
"entity.edit": (name: string, id: number) => `${name} 수정 (#${id})`,
|
|
36
|
+
"entity.list": (name: string) => `${name} 목록`,
|
|
37
|
+
"entity.listManage": (name: string) => `${name} 목록 관리`,
|
|
38
|
+
"error.badRequest": "잘못된 요청입니다",
|
|
39
|
+
"error.duplicateRow": "중복된 데이터입니다",
|
|
40
|
+
"error.forbidden": "권한이 없습니다",
|
|
41
|
+
"error.internalServerError": "서버 오류가 발생했습니다",
|
|
42
|
+
"error.notFound": "찾을 수 없습니다",
|
|
43
|
+
"error.unauthorized": "인증이 필요합니다",
|
|
44
|
+
notFound: (name: string, id: number) => `존재하지 않는 ${name} ID ${id}`,
|
|
45
|
+
"validation.email": "올바른 이메일 형식이 아닙니다",
|
|
46
|
+
"validation.maxLength": (field: string, max: number) =>
|
|
47
|
+
`${field}은(는) 최대 ${max}자까지 입력할 수 있습니다`,
|
|
48
|
+
"validation.minLength": (field: string, min: number) =>
|
|
49
|
+
`${field}은(는) 최소 ${min}자 이상이어야 합니다`,
|
|
50
|
+
"validation.required": (field: string) => `${josa(field, "은는")} 필수입니다`,
|
|
51
|
+
"validation.url": "올바른 URL 형식이 아닙니다",
|
|
52
|
+
// components
|
|
53
|
+
"component.asyncSelect.loading": "로딩 중...",
|
|
54
|
+
"component.asyncSelect.noOptions": "옵션이 없습니다",
|
|
55
|
+
"component.asyncSelect.noResults": "결과가 없습니다",
|
|
56
|
+
"component.asyncSelect.selectPlaceholder": "선택하세요",
|
|
57
|
+
"component.datePicker.pickDate": "날짜 선택",
|
|
58
|
+
"component.datePicker.placeholder": "날짜 선택",
|
|
59
|
+
"component.fileInput.browseFiles": "파일 선택",
|
|
60
|
+
"component.fileInput.dropZone": "파일을 드래그하여 업로드하거나 클릭하세요",
|
|
61
|
+
};
|