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.
Files changed (71) hide show
  1. package/README.md +365 -2
  2. package/index.js +632 -0
  3. package/package.json +30 -20
  4. package/template/src/README.md +274 -0
  5. package/template/src/packages/api/.swcrc +18 -0
  6. package/template/src/packages/api/custom-sequencer.ts +23 -0
  7. package/template/src/packages/api/database/docker-compose.yml +19 -0
  8. package/template/src/packages/api/database/fixtures/init.sh +15 -0
  9. package/template/src/packages/api/database/scripts/dump.sh +62 -0
  10. package/template/src/packages/api/database/scripts/seed.sh +60 -0
  11. package/template/src/packages/api/package.json +55 -0
  12. package/template/src/packages/api/src/application/.gitkeep +1 -0
  13. package/template/src/packages/api/src/i18n/en.ts +59 -0
  14. package/template/src/packages/api/src/i18n/ko.ts +57 -0
  15. package/template/src/packages/api/src/index.ts +6 -0
  16. package/template/src/packages/api/src/migrations/.gitkeep +1 -0
  17. package/template/src/packages/api/src/sonamu.config.ts +162 -0
  18. package/template/src/packages/api/src/testing/fixture.ts +6 -0
  19. package/template/src/packages/api/src/testing/global.ts +6 -0
  20. package/template/src/packages/api/src/testing/setup-mocks.ts +44 -0
  21. package/template/src/packages/api/src/typings/fastify.d.ts +7 -0
  22. package/template/src/packages/api/src/typings/sonamu.d.ts +19 -0
  23. package/template/src/packages/api/src/utils/subset-loaders.ts +11 -0
  24. package/template/src/packages/api/tsconfig.json +60 -0
  25. package/template/src/packages/api/tsconfig.schemas.json +5 -0
  26. package/template/src/packages/api/tsconfig.types.json +5 -0
  27. package/template/src/packages/api/vitest.config.ts +36 -0
  28. package/template/src/packages/web/.sonamu.env +2 -0
  29. package/template/src/{web → packages/web}/index.html +3 -3
  30. package/template/src/packages/web/package.json +49 -0
  31. package/template/src/packages/web/src/App.tsx +17 -0
  32. package/template/src/packages/web/src/admin-common/ApiLogViewer.tsx +285 -0
  33. package/template/src/packages/web/src/admin-common/CommonModal.tsx +91 -0
  34. package/template/src/packages/web/src/contexts/sonamu-provider.tsx +41 -0
  35. package/template/src/packages/web/src/entry-client.tsx +72 -0
  36. package/template/src/packages/web/src/entry-server.generated.tsx +58 -0
  37. package/template/src/packages/web/src/i18n/en.ts +63 -0
  38. package/template/src/packages/web/src/i18n/ko.ts +61 -0
  39. package/template/src/packages/web/src/routeTree.gen.ts +27 -0
  40. package/template/src/packages/web/src/routes/__root.tsx +44 -0
  41. package/template/src/packages/web/src/routes/index.tsx +14 -0
  42. package/template/src/packages/web/src/styles/tailwind.css +5 -0
  43. package/template/src/packages/web/src/vite-env.d.ts +2 -0
  44. package/template/src/packages/web/tailwind.config.ts +8 -0
  45. package/template/src/{web → packages/web}/tsconfig.json +5 -3
  46. package/template/src/packages/web/vite.config.ts +51 -0
  47. package/template/src/api/README.md +0 -3
  48. package/template/src/api/database/docker-compose.yml +0 -17
  49. package/template/src/api/package.json +0 -39
  50. package/template/src/api/sonamu.config.json +0 -11
  51. package/template/src/api/src/configs/db.ts +0 -25
  52. package/template/src/api/src/index.ts +0 -36
  53. package/template/src/api/src/testing/bootstrap.ts +0 -20
  54. package/template/src/api/src/testing/fixture.ts +0 -18
  55. package/template/src/api/src/testing/global.ts +0 -7
  56. package/template/src/api/src/typings/sonamu.d.ts +0 -5
  57. package/template/src/api/tsconfig.json +0 -115
  58. package/template/src/api/vite.config.mts +0 -15
  59. package/template/src/web/package.json +0 -40
  60. package/template/src/web/public/vite.svg +0 -1
  61. package/template/src/web/src/App.css +0 -34
  62. package/template/src/web/src/App.tsx +0 -15
  63. package/template/src/web/src/assets/react.svg +0 -1
  64. package/template/src/web/src/index.css +0 -76
  65. package/template/src/web/src/main.tsx +0 -30
  66. package/template/src/web/src/pages/index.tsx +0 -11
  67. package/template/src/web/src/vite-env.d.ts +0 -1
  68. package/template/src/web/vite.config.ts +0 -20
  69. /package/template/src/{web/src/services → packages/api/database/dumps}/.gitkeep +0 -0
  70. /package/template/src/{api/database/scripts/init.sql → packages/web/src/services/.gitkeep} +0 -0
  71. /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
+ };