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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-sonamu",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "Create a new Sonamu project",
5
5
  "keywords": [
6
6
  "sonamu",
@@ -38,7 +38,7 @@
38
38
  "knex": "^3.1.0",
39
39
  "pg": "^8.16.3",
40
40
  "radashi": "^12.2.0",
41
- "sonamu": "^0.7.49",
41
+ "sonamu": "^0.7.50",
42
42
  "zod": "^4.1.12"
43
43
  },
44
44
  "devDependencies": {
@@ -10,7 +10,7 @@
10
10
  "preview": "vite preview"
11
11
  },
12
12
  "dependencies": {
13
- "@sonamu-kit/react-components": "^0.1.7",
13
+ "@sonamu-kit/react-components": "^0.1.8",
14
14
  "@tanstack/react-query": "^5.90.12",
15
15
  "@tanstack/react-router": "1.143.11",
16
16
  "axios": "^1.13.2",
@@ -1,16 +1,42 @@
1
- import { type ReactNode, Suspense } from "react";
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
- <div className="min-h-screen bg-gray-50">
10
- <div className="max-w-7xl mx-auto">
11
- <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
12
- </div>
13
- </div>
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 { SonamuProvider, type SonamuContextValue } from "@sonamu-kit/react-components";
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
- // Temporary type until sd.generated is created
5
- type EmptyDictionary = Record<string, never>;
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<EmptyDictionary> {
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 (_files: File[]) => {
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 - returns a function until i18n is set up
33
- // Components like Pagination call SD("key")(args), so we must return a function
34
- const sd_config = (key: string): any => {
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 <SonamuProvider<EmptyDictionary> {...sonamuConfig}>{children}</SonamuProvider>;
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
- export const routeTree = rootRoute.addChildren([IndexRouteWithChildren])
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 className="flex flex-col items-center justify-center min-h-screen">
10
- <h1 className="text-4xl font-bold mb-4">Welcome to Sonamu</h1>
11
- <p className="text-gray-600">Start building your application</p>
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
+ }