create-sonamu 0.0.8 → 0.1.0
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/package.json +1 -1
- package/template/src/packages/api/package.json +1 -1
- package/template/src/packages/web/package.json +1 -1
- package/template/src/packages/web/src/App.tsx +32 -6
- package/template/src/packages/web/src/components/Sidebar.tsx +107 -0
- package/template/src/packages/web/src/contexts/sonamu-provider.tsx +28 -10
- package/template/src/packages/web/src/i18n/sd.generated.ts +41 -0
- package/template/src/packages/web/src/routeTree.gen.ts +18 -1
- package/template/src/packages/web/src/routes/admin/index.tsx +24 -0
- package/template/src/packages/web/src/routes/index.tsx +10 -3
- package/template/src/packages/web/src/services/sonamu.shared.ts +22 -0
package/package.json
CHANGED
|
@@ -1,16 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SidebarProvider } from "@sonamu-kit/react-components/components";
|
|
2
|
+
import { useRouterState } from "@tanstack/react-router";
|
|
3
|
+
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
|
4
|
+
import { type ReactNode, Suspense, useEffect } from "react";
|
|
5
|
+
import Sidebar from "./components/Sidebar";
|
|
6
|
+
import { setLocale } from "./i18n/sd.generated";
|
|
2
7
|
|
|
3
8
|
interface AppProps {
|
|
4
9
|
children?: ReactNode;
|
|
5
10
|
}
|
|
6
11
|
|
|
12
|
+
// 사이드바를 숨길 경로 목록
|
|
13
|
+
// TODO: 로그인/회원가입 페이지 추가 시 여기에 추가
|
|
14
|
+
const hideSidebarPaths = ["/login", "/admin/login", "/signup"];
|
|
15
|
+
|
|
7
16
|
function App({ children }: AppProps) {
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
// 브라우저 locale 감지
|
|
19
|
+
const browserLocale = navigator.language.split("-")[0];
|
|
20
|
+
if (["ko", "en"].includes(browserLocale)) {
|
|
21
|
+
setLocale(browserLocale as "ko" | "en");
|
|
22
|
+
}
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
26
|
+
const showSidebar = !hideSidebarPaths.includes(pathname);
|
|
27
|
+
|
|
8
28
|
return (
|
|
9
|
-
|
|
10
|
-
<
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
29
|
+
<>
|
|
30
|
+
<SidebarProvider className="h-screen">
|
|
31
|
+
<div className="flex h-screen md:flex-row flex-col w-full">
|
|
32
|
+
{showSidebar && <Sidebar />}
|
|
33
|
+
<div className="flex-1 p-8 md:p-4 bg-white overflow-auto">
|
|
34
|
+
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</SidebarProvider>
|
|
38
|
+
{import.meta.env.DEV && <TanStackRouterDevtools initialIsOpen={false} />}
|
|
39
|
+
</>
|
|
14
40
|
);
|
|
15
41
|
}
|
|
16
42
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Sidebar as SidebarComponent,
|
|
4
|
+
SidebarContent,
|
|
5
|
+
SidebarFooter,
|
|
6
|
+
SidebarGroup,
|
|
7
|
+
SidebarHeader,
|
|
8
|
+
SidebarMenuButton,
|
|
9
|
+
SidebarMenuItem,
|
|
10
|
+
SidebarMenu,
|
|
11
|
+
} from "@sonamu-kit/react-components/components";
|
|
12
|
+
import { Link, useRouterState } from "@tanstack/react-router";
|
|
13
|
+
import type React from "react";
|
|
14
|
+
import { useSonamuContext } from "@/contexts/sonamu-provider";
|
|
15
|
+
import { SD } from "@/i18n/sd.generated";
|
|
16
|
+
import HomeIcon from "~icons/lucide/home";
|
|
17
|
+
import LogOutIcon from "~icons/lucide/log-out";
|
|
18
|
+
// TODO: 필요한 아이콘 추가
|
|
19
|
+
// import UsersIcon from "~icons/lucide/users";
|
|
20
|
+
// import SettingsIcon from "~icons/lucide/settings";
|
|
21
|
+
|
|
22
|
+
interface SidebarProps {
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MenuItemProps {
|
|
27
|
+
title: string;
|
|
28
|
+
path: string;
|
|
29
|
+
icon?: React.FC<React.SVGProps<SVGSVGElement>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 관리자용 메뉴
|
|
33
|
+
const adminMenuItems: MenuItemProps[] = [
|
|
34
|
+
{ title: "Dashboard", path: "/admin", icon: HomeIcon },
|
|
35
|
+
// TODO: 엔티티 추가 시 메뉴 추가
|
|
36
|
+
// { title: "Users", path: "/admin/users", icon: UsersIcon },
|
|
37
|
+
// { title: "Settings", path: "/admin/settings", icon: SettingsIcon },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// 일반 사용자용 메뉴
|
|
41
|
+
const userMenuItems: MenuItemProps[] = [
|
|
42
|
+
{ title: "Home", path: "/", icon: HomeIcon },
|
|
43
|
+
// TODO: 사용자용 메뉴 추가
|
|
44
|
+
// { title: "Profile", path: "/profile", icon: UserIcon },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export default function Sidebar({ className }: SidebarProps) {
|
|
48
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
49
|
+
const { auth } = useSonamuContext();
|
|
50
|
+
const { user, logout } = auth;
|
|
51
|
+
|
|
52
|
+
// 경로에 따라 메뉴 및 타이틀 분기
|
|
53
|
+
const isAdmin = pathname.startsWith("/admin");
|
|
54
|
+
const menuItems = isAdmin ? adminMenuItems : userMenuItems;
|
|
55
|
+
const title = isAdmin ? "Admin" : "Sonamu App";
|
|
56
|
+
|
|
57
|
+
const isActive = (path: string) => {
|
|
58
|
+
if (path === "/admin" || path === "/") {
|
|
59
|
+
return pathname === path || pathname === `${path}/`;
|
|
60
|
+
}
|
|
61
|
+
return pathname.startsWith(path);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<SidebarComponent collapsible="none" className={`h-screen sticky top-0 ${className || ""}`}>
|
|
66
|
+
{/* Header */}
|
|
67
|
+
<SidebarHeader className="border-b border-sidebar-border px-4 py-4">
|
|
68
|
+
<div className="flex items-center gap-2">
|
|
69
|
+
<span className="text-base font-semibold">{title}</span>
|
|
70
|
+
</div>
|
|
71
|
+
{user && (
|
|
72
|
+
<div className="text-sm text-sidebar-foreground/70 mt-1">
|
|
73
|
+
{user.username ?? user.email ?? "User"}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</SidebarHeader>
|
|
77
|
+
|
|
78
|
+
{/* Menu Content */}
|
|
79
|
+
<SidebarContent className="flex-1 overflow-y-auto">
|
|
80
|
+
<SidebarGroup className="px-2 py-2">
|
|
81
|
+
<SidebarMenu>
|
|
82
|
+
{menuItems.map((item) => (
|
|
83
|
+
<SidebarMenuItem key={item.path}>
|
|
84
|
+
<SidebarMenuButton asChild isActive={isActive(item.path)}>
|
|
85
|
+
<Link to={item.path} className="!no-underline">
|
|
86
|
+
{item.icon && <item.icon className="size-4" />}
|
|
87
|
+
<span>{item.title}</span>
|
|
88
|
+
</Link>
|
|
89
|
+
</SidebarMenuButton>
|
|
90
|
+
</SidebarMenuItem>
|
|
91
|
+
))}
|
|
92
|
+
</SidebarMenu>
|
|
93
|
+
</SidebarGroup>
|
|
94
|
+
</SidebarContent>
|
|
95
|
+
|
|
96
|
+
{/* Footer */}
|
|
97
|
+
{user && (
|
|
98
|
+
<SidebarFooter className="border-t border-sidebar-border px-4 py-3">
|
|
99
|
+
<Button variant="destructive" onClick={logout} className="w-full">
|
|
100
|
+
<LogOutIcon className="size-4 mr-2" />
|
|
101
|
+
{SD("common.logout")}
|
|
102
|
+
</Button>
|
|
103
|
+
</SidebarFooter>
|
|
104
|
+
)}
|
|
105
|
+
</SidebarComponent>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type SonamuContextValue,
|
|
3
|
+
type SonamuFile,
|
|
4
|
+
SonamuProvider,
|
|
5
|
+
useSonamuBaseContext,
|
|
6
|
+
} from "@sonamu-kit/react-components";
|
|
2
7
|
import type { ReactNode } from "react";
|
|
8
|
+
import type { MergedDictionary } from "@/i18n/sd.generated";
|
|
9
|
+
import { SD } from "@/i18n/sd.generated";
|
|
3
10
|
|
|
4
|
-
//
|
|
5
|
-
type
|
|
11
|
+
// TODO: User 엔티티 추가 후 아래 타입들을 지정하세요
|
|
12
|
+
// - UserSubsetSS: 세션에 저장되는 User 타입 (예: import type { UserSubsetSS } from "@/services/sonamu.generated")
|
|
13
|
+
// - UserLoginParams: 로그인 파라미터 타입 (예: import type { UserLoginParams } from "@/services/user/user.types")
|
|
14
|
+
export function useSonamuContext() {
|
|
15
|
+
return useSonamuBaseContext<MergedDictionary, any, any>();
|
|
16
|
+
}
|
|
6
17
|
|
|
7
|
-
export function createSonamuConfig(): SonamuContextValue<
|
|
18
|
+
export function createSonamuConfig(): SonamuContextValue<MergedDictionary, any, any> {
|
|
8
19
|
// Auth configuration
|
|
9
20
|
const auth_config = {
|
|
10
21
|
user: null,
|
|
@@ -23,16 +34,19 @@ export function createSonamuConfig(): SonamuContextValue<EmptyDictionary> {
|
|
|
23
34
|
};
|
|
24
35
|
|
|
25
36
|
// Uploader configuration
|
|
26
|
-
const uploader_config = async (
|
|
37
|
+
const uploader_config = async (files: File[]): Promise<SonamuFile[]> => {
|
|
27
38
|
// TODO: Implement file upload logic
|
|
39
|
+
if (files.length === 0) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
console.log("File upload not implemented yet");
|
|
29
44
|
return [];
|
|
30
45
|
};
|
|
31
46
|
|
|
32
|
-
// SD configuration
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return (..._args: any[]) => key;
|
|
47
|
+
// SD configuration
|
|
48
|
+
const sd_config = <K extends keyof MergedDictionary>(key: K) => {
|
|
49
|
+
return SD(key as string);
|
|
36
50
|
};
|
|
37
51
|
|
|
38
52
|
return { auth: auth_config, uploader: uploader_config, SD: sd_config };
|
|
@@ -40,5 +54,9 @@ export function createSonamuConfig(): SonamuContextValue<EmptyDictionary> {
|
|
|
40
54
|
|
|
41
55
|
export function SonamuProviderWrapper({ children }: { children: ReactNode }) {
|
|
42
56
|
const sonamuConfig = createSonamuConfig();
|
|
43
|
-
return
|
|
57
|
+
return (
|
|
58
|
+
<SonamuProvider<MergedDictionary, any, any> {...sonamuConfig}>
|
|
59
|
+
{children}
|
|
60
|
+
</SonamuProvider>
|
|
61
|
+
);
|
|
44
62
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// 자동 생성 파일 - sonamu sync로 갱신됨
|
|
2
|
+
// 초기 빈 상태 - sonamu dev 실행 시 실제 내용으로 대체됩니다.
|
|
3
|
+
|
|
4
|
+
const DEFAULT_LOCALE = "ko" as const;
|
|
5
|
+
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
|
6
|
+
let _currentLocale: (typeof SUPPORTED_LOCALES)[number] = DEFAULT_LOCALE;
|
|
7
|
+
|
|
8
|
+
export function setLocale(locale: (typeof SUPPORTED_LOCALES)[number]) {
|
|
9
|
+
_currentLocale = locale;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getCurrentLocale(): (typeof SUPPORTED_LOCALES)[number] {
|
|
13
|
+
return _currentLocale;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 초기 빈 dictionary
|
|
17
|
+
const dictionaries: Record<string, Record<string, string | ((...args: any[]) => string)>> = {
|
|
18
|
+
ko: {
|
|
19
|
+
"common.logout": "로그아웃",
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
"common.logout": "Logout",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// react-components의 Dictionary 타입과 호환되는 타입
|
|
27
|
+
export type MergedDictionary = Record<string, string | ((...args: any[]) => string)>;
|
|
28
|
+
export type DictKey = string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sonamu Dictionary 함수
|
|
32
|
+
* locale에 맞는 번역 텍스트를 반환합니다.
|
|
33
|
+
*/
|
|
34
|
+
export function SD<K extends DictKey>(key: K): string {
|
|
35
|
+
const dict = dictionaries[_currentLocale] ?? dictionaries[DEFAULT_LOCALE];
|
|
36
|
+
const value = dict?.[key] ?? key;
|
|
37
|
+
if (typeof value === "function") {
|
|
38
|
+
return value();
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { Route as rootRoute } from './routes/__root'
|
|
7
7
|
import { Route as IndexRoute } from './routes/index'
|
|
8
|
+
import { Route as AdminIndexRoute } from './routes/admin/index'
|
|
8
9
|
|
|
9
10
|
const IndexRouteWithChildren = IndexRoute.update({
|
|
10
11
|
id: '/',
|
|
@@ -12,7 +13,16 @@ const IndexRouteWithChildren = IndexRoute.update({
|
|
|
12
13
|
getParentRoute: () => rootRoute,
|
|
13
14
|
} as any)
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
const AdminIndexRouteWithChildren = AdminIndexRoute.update({
|
|
17
|
+
id: '/admin/',
|
|
18
|
+
path: '/admin/',
|
|
19
|
+
getParentRoute: () => rootRoute,
|
|
20
|
+
} as any)
|
|
21
|
+
|
|
22
|
+
export const routeTree = rootRoute.addChildren([
|
|
23
|
+
IndexRouteWithChildren,
|
|
24
|
+
AdminIndexRouteWithChildren,
|
|
25
|
+
])
|
|
16
26
|
|
|
17
27
|
declare module '@tanstack/react-router' {
|
|
18
28
|
interface FileRoutesByPath {
|
|
@@ -23,5 +33,12 @@ declare module '@tanstack/react-router' {
|
|
|
23
33
|
preLoaderRoute: typeof IndexRouteWithChildren
|
|
24
34
|
parentRoute: typeof rootRoute
|
|
25
35
|
}
|
|
36
|
+
'/admin/': {
|
|
37
|
+
id: '/admin/'
|
|
38
|
+
path: '/admin/'
|
|
39
|
+
fullPath: '/admin/'
|
|
40
|
+
preLoaderRoute: typeof AdminIndexRouteWithChildren
|
|
41
|
+
parentRoute: typeof rootRoute
|
|
42
|
+
}
|
|
26
43
|
}
|
|
27
44
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
|
|
3
|
+
export const Route = createFileRoute("/admin/")({
|
|
4
|
+
component: AdminDashboard,
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
function AdminDashboard() {
|
|
8
|
+
return (
|
|
9
|
+
<div>
|
|
10
|
+
<h1 className="text-2xl font-bold mb-4">Admin Dashboard</h1>
|
|
11
|
+
<p className="text-gray-600">Welcome to the admin panel. Start building your application.</p>
|
|
12
|
+
|
|
13
|
+
{/* TODO: 대시보드 위젯 추가 */}
|
|
14
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
|
|
15
|
+
<div className="p-6 bg-gray-50 rounded-lg border">
|
|
16
|
+
<h3 className="font-semibold mb-2">Quick Start</h3>
|
|
17
|
+
<p className="text-sm text-gray-600">
|
|
18
|
+
Add entities with <code className="bg-gray-200 px-1 rounded">sonamu entity add</code>
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -6,9 +6,16 @@ export const Route = createFileRoute("/")({
|
|
|
6
6
|
|
|
7
7
|
function HomePage() {
|
|
8
8
|
return (
|
|
9
|
-
<div
|
|
10
|
-
<h1 className="text-
|
|
11
|
-
<p className="text-gray-600">Start building your application
|
|
9
|
+
<div>
|
|
10
|
+
<h1 className="text-2xl font-bold mb-4">Welcome to Sonamu</h1>
|
|
11
|
+
<p className="text-gray-600">Start building your application.</p>
|
|
12
|
+
|
|
13
|
+
{/* TODO: 사용자용 콘텐츠 추가 */}
|
|
14
|
+
<div className="mt-8">
|
|
15
|
+
<p className="text-sm text-gray-500">
|
|
16
|
+
This is the user-facing home page. Customize it as needed.
|
|
17
|
+
</p>
|
|
18
|
+
</div>
|
|
12
19
|
</div>
|
|
13
20
|
);
|
|
14
21
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 자동 생성 파일 - sonamu sync로 갱신됨
|
|
2
|
+
// 초기 빈 상태 - sonamu dev 실행 시 실제 내용으로 대체됩니다.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ISO 8601 및 타임존 포맷의 날짜 문자열을 Date 객체로 변환하는 reviver
|
|
6
|
+
*/
|
|
7
|
+
export function dateReviver(_key: string, value: any): any {
|
|
8
|
+
if (typeof value === "string") {
|
|
9
|
+
// ISO 8601 형식: 2024-01-15T09:30:00.000Z 또는 2024-01-15T09:30:00+09:00
|
|
10
|
+
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?$/;
|
|
11
|
+
// Timezone 포맷: 2024-01-15 09:30:00+09:00
|
|
12
|
+
const timezoneRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
|
|
13
|
+
|
|
14
|
+
if (
|
|
15
|
+
(isoRegex.test(value) || timezoneRegex.test(value)) &&
|
|
16
|
+
new Date(value).toString() !== "Invalid Date"
|
|
17
|
+
) {
|
|
18
|
+
return new Date(value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|