pejay-ui 1.3.0 → 1.3.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 +24 -2
- package/package.json +2 -2
- package/registry.json +16 -0
- package/templates/layouts/lv1/app-layout.tsx +194 -0
- package/templates/layouts/lv1/index.ts +2 -0
- package/templates/layouts/lv1/sidebar-menu.tsx +120 -0
package/README.md
CHANGED
|
@@ -130,14 +130,36 @@ Below is the list of components you can add. Each has a copyable command block w
|
|
|
130
130
|
npx pejay-ui add dropdown/multiselect-input
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
+
### Layouts
|
|
134
|
+
|
|
135
|
+
* **`layouts/lv1`**: A responsive, collapsible sidebar-based application layout.
|
|
136
|
+
```bash
|
|
137
|
+
npx pejay-ui add layouts/lv1
|
|
138
|
+
```
|
|
139
|
+
|
|
133
140
|
### Scaffolds & Templates
|
|
134
141
|
|
|
135
142
|
* **`tanstack-query-client`**: Bare-bone TanStack Query client and context provider setup, copied directly into your project's `src/tanstack-query/`.
|
|
136
143
|
```bash
|
|
137
144
|
npx pejay-ui add tanstack-query-client
|
|
138
145
|
```
|
|
139
|
-
* **`react-router-client`**: Bare-bone React Router client layout, routing structure, and route guard setup
|
|
146
|
+
* **`react-router-client`**: Bare-bone React Router client layout, routing structure, and route guard setup, copied into `src/react-router/`.
|
|
140
147
|
```bash
|
|
141
148
|
npx pejay-ui add react-router-client
|
|
142
149
|
```
|
|
143
|
-
|
|
150
|
+
* **`tanstack-router-client`**: TanStack Router setup with layouts, route guards, and file-based route stubs, copied into `src/tanstack-router/`.
|
|
151
|
+
```bash
|
|
152
|
+
npx pejay-ui add tanstack-router-client
|
|
153
|
+
```
|
|
154
|
+
* **`axios-client`**: Axios instance with interceptors, request helpers, and a sample API module, copied into `src/axios/`.
|
|
155
|
+
```bash
|
|
156
|
+
npx pejay-ui add axios-client
|
|
157
|
+
```
|
|
158
|
+
* **`redux-store-client`**: Redux Toolkit store setup with `redux-persist`, reducers, slices, and selectors, copied into `src/redux-store/`.
|
|
159
|
+
```bash
|
|
160
|
+
npx pejay-ui add redux-store-client
|
|
161
|
+
```
|
|
162
|
+
* **`rtk-query-client`**: RTK Query base API with `fetchBaseQuery`, tag management, middleware, and a sample endpoint, copied into `src/rtk-query/`.
|
|
163
|
+
```bash
|
|
164
|
+
npx pejay-ui add rtk-query-client
|
|
165
|
+
```
|
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -236,5 +236,21 @@
|
|
|
236
236
|
"targetDirName": "rtk-query",
|
|
237
237
|
"files": ["templates/scaffolds/rtk-query"],
|
|
238
238
|
"peerDependencies": ["@reduxjs/toolkit", "react-redux"]
|
|
239
|
+
},
|
|
240
|
+
"layouts/lv1": {
|
|
241
|
+
"name": "AppLayout",
|
|
242
|
+
"category": "layouts",
|
|
243
|
+
"files": [
|
|
244
|
+
"templates/layouts/lv1/app-layout.tsx",
|
|
245
|
+
"templates/layouts/lv1/sidebar-menu.tsx",
|
|
246
|
+
"templates/layouts/lv1/index.ts"
|
|
247
|
+
],
|
|
248
|
+
"utils": ["cn.ts"],
|
|
249
|
+
"peerDependencies": [
|
|
250
|
+
"clsx",
|
|
251
|
+
"tailwind-merge",
|
|
252
|
+
"lucide-react"
|
|
253
|
+
]
|
|
239
254
|
}
|
|
240
255
|
}
|
|
256
|
+
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useState, useLayoutEffect, useEffect } from "react";
|
|
2
|
+
import { Menu, PanelLeftOpen, PanelRightOpen, X } from "lucide-react";
|
|
3
|
+
import { cn } from "@/utils/cn";
|
|
4
|
+
import { MenuSection, SidebarMenu } from "./sidebar-menu";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Config
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
const menuConfig: MenuSection[] = [];
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
type Variant = "none" | "semi" | "full" | "hybrid";
|
|
18
|
+
type ActiveVariant = "none" | "semi" | "full";
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Hook
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
const useAppMenu = (variant: Variant) => {
|
|
25
|
+
const [expandMenu, setExpandMenu] = useState(false);
|
|
26
|
+
const [activeVariant, setActiveVariant] = useState<ActiveVariant>("none");
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setExpandMenu(false); };
|
|
30
|
+
window.addEventListener("keydown", onKey);
|
|
31
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const onResize = () => {
|
|
36
|
+
const w = window.innerWidth;
|
|
37
|
+
if (variant === "hybrid") {
|
|
38
|
+
setActiveVariant(w >= 1024 ? "none" : w >= 768 ? "semi" : "full");
|
|
39
|
+
} else if (variant === "full") {
|
|
40
|
+
setActiveVariant(w >= 768 ? "none" : "full");
|
|
41
|
+
} else {
|
|
42
|
+
setActiveVariant(variant as ActiveVariant);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
window.addEventListener("resize", onResize);
|
|
46
|
+
onResize();
|
|
47
|
+
return () => window.removeEventListener("resize", onResize);
|
|
48
|
+
}, [variant]);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
activeVariant,
|
|
52
|
+
isExpanded: activeVariant === "none" || expandMenu,
|
|
53
|
+
expandMenu,
|
|
54
|
+
setExpandMenu,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// AppLayout
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
export const AppLayout = ({ variant = "hybrid" }: { variant?: Variant }) => {
|
|
63
|
+
const { activeVariant, isExpanded, expandMenu, setExpandMenu } = useAppMenu(variant);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex flex-row flex-1 w-full h-screen overflow-hidden bg-[#0f0f11]">
|
|
67
|
+
{activeVariant === "full" && isExpanded && (
|
|
68
|
+
<div
|
|
69
|
+
className="fixed inset-0 bg-black/60 z-40 backdrop-blur-sm"
|
|
70
|
+
onClick={() => setExpandMenu(false)}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
<SidePanel
|
|
75
|
+
activeVariant={activeVariant}
|
|
76
|
+
isExpanded={isExpanded}
|
|
77
|
+
expandMenu={expandMenu}
|
|
78
|
+
setExpandMenu={setExpandMenu}
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
<div className="flex flex-col flex-1 h-full overflow-hidden">
|
|
82
|
+
<TopBar
|
|
83
|
+
activeVariant={activeVariant}
|
|
84
|
+
isExpanded={isExpanded}
|
|
85
|
+
setExpandMenu={setExpandMenu}
|
|
86
|
+
/>
|
|
87
|
+
<MainArea />
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// SidePanel
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
const SidePanel = ({
|
|
98
|
+
activeVariant,
|
|
99
|
+
isExpanded,
|
|
100
|
+
expandMenu,
|
|
101
|
+
setExpandMenu,
|
|
102
|
+
}: {
|
|
103
|
+
activeVariant: ActiveVariant;
|
|
104
|
+
isExpanded: boolean;
|
|
105
|
+
expandMenu: boolean;
|
|
106
|
+
setExpandMenu: (v: boolean) => void;
|
|
107
|
+
}) => {
|
|
108
|
+
const sidebar = (
|
|
109
|
+
<div className="flex flex-col bg-[#141417] border-r border-white/[0.06] h-full p-2 gap-2 w-full overflow-hidden shrink-0">
|
|
110
|
+
{/* Header */}
|
|
111
|
+
{activeVariant === "none" ? (
|
|
112
|
+
<div className="px-2.5 py-2 shrink-0 mb-1 flex items-center gap-2 text-sm font-bold text-white/80 select-none">
|
|
113
|
+
<Menu size={18} />
|
|
114
|
+
<span>Menu</span>
|
|
115
|
+
</div>
|
|
116
|
+
) : (
|
|
117
|
+
<button
|
|
118
|
+
onClick={() => setExpandMenu(!expandMenu)}
|
|
119
|
+
aria-expanded={isExpanded}
|
|
120
|
+
className={cn(
|
|
121
|
+
"w-full shrink-0 flex items-center gap-2 rounded-lg text-white/60 hover:bg-white/5 hover:text-white/90 transition-all duration-150 cursor-pointer select-none font-bold text-sm",
|
|
122
|
+
isExpanded ? "px-2.5 h-9 justify-start mb-1" : "justify-center aspect-square p-2.5 w-auto"
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{isExpanded ? <PanelLeftOpen size={18} /> : <PanelRightOpen size={18} />}
|
|
126
|
+
{isExpanded && <span>Menu</span>}
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
<SidebarMenu
|
|
131
|
+
config={menuConfig}
|
|
132
|
+
isExpanded={activeVariant === "full" ? true : isExpanded}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (activeVariant === "full") {
|
|
138
|
+
return (
|
|
139
|
+
<div className={cn(
|
|
140
|
+
"fixed top-0 left-0 h-full z-50 w-52 transform transition-transform duration-300",
|
|
141
|
+
isExpanded ? "translate-x-0" : "-translate-x-full"
|
|
142
|
+
)}>
|
|
143
|
+
{sidebar}
|
|
144
|
+
<div className={cn(
|
|
145
|
+
"absolute left-full top-3 ml-3 transition-opacity duration-150",
|
|
146
|
+
expandMenu ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
147
|
+
)}>
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => setExpandMenu(false)}
|
|
150
|
+
className="w-9 h-9 flex items-center justify-center rounded-lg bg-white/10 text-white hover:bg-white/20 border border-white/10 cursor-pointer"
|
|
151
|
+
>
|
|
152
|
+
<X size={16} />
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div className={cn("h-full transition-all duration-200 shrink-0", isExpanded ? "w-52" : "w-max")}>
|
|
161
|
+
{sidebar}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// TopBar / MainArea
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
const TopBar = ({
|
|
171
|
+
activeVariant,
|
|
172
|
+
isExpanded,
|
|
173
|
+
setExpandMenu,
|
|
174
|
+
}: {
|
|
175
|
+
activeVariant: ActiveVariant;
|
|
176
|
+
isExpanded: boolean;
|
|
177
|
+
setExpandMenu: (v: boolean) => void;
|
|
178
|
+
}) => (
|
|
179
|
+
<div className="flex items-center h-14 px-3 border-b border-white/[0.06] bg-[#141417] shrink-0">
|
|
180
|
+
{activeVariant === "full" && !isExpanded && (
|
|
181
|
+
<button
|
|
182
|
+
onClick={() => setExpandMenu(true)}
|
|
183
|
+
aria-label="Open Menu"
|
|
184
|
+
className="w-9 h-9 flex items-center justify-center rounded-lg text-white/50 hover:bg-white/5 hover:text-white/80 transition-all duration-150 cursor-pointer"
|
|
185
|
+
>
|
|
186
|
+
<PanelRightOpen size={18} />
|
|
187
|
+
</button>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const MainArea = () => (
|
|
193
|
+
<div className="flex-1 w-full bg-[#0f0f11] h-full overflow-y-auto" />
|
|
194
|
+
);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Types
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
export interface MenuItem {
|
|
9
|
+
id: string | number;
|
|
10
|
+
label: string;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
link?: string;
|
|
13
|
+
children?: { id: string | number; label: string; link?: string; icon?: ReactNode }[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type MenuSection =
|
|
17
|
+
| { id: string | number; type: "item"; label: string; icon?: ReactNode; link?: string; divider?: boolean }
|
|
18
|
+
| { id: string | number; type: "group"; label: string; items: MenuItem[]; divider?: boolean }
|
|
19
|
+
| { id: string | number; type: "bottom"; items: MenuItem[]; divider?: boolean };
|
|
20
|
+
|
|
21
|
+
export interface SidebarMenuProps {
|
|
22
|
+
config: MenuSection[];
|
|
23
|
+
isExpanded: boolean;
|
|
24
|
+
activeId?: string | number;
|
|
25
|
+
onItemClick?: (id: string | number) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Menu Button
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
const MenuBtn = ({
|
|
33
|
+
label,
|
|
34
|
+
icon,
|
|
35
|
+
isExpanded,
|
|
36
|
+
isActive,
|
|
37
|
+
onClick,
|
|
38
|
+
}: {
|
|
39
|
+
label: string;
|
|
40
|
+
icon?: ReactNode;
|
|
41
|
+
isExpanded: boolean;
|
|
42
|
+
isActive?: boolean;
|
|
43
|
+
onClick?: () => void;
|
|
44
|
+
}) => (
|
|
45
|
+
<button
|
|
46
|
+
onClick={onClick}
|
|
47
|
+
className={cn(
|
|
48
|
+
"w-full flex items-center gap-2.5 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer select-none",
|
|
49
|
+
isExpanded ? "px-2.5 py-2 justify-start" : "p-2.5 justify-center aspect-square w-auto",
|
|
50
|
+
isActive ? "bg-white/10 text-white" : "text-white/50 hover:bg-white/5 hover:text-white/80"
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<span className="shrink-0 w-[18px] h-[18px] flex items-center justify-center">
|
|
54
|
+
{icon ?? <span className="text-[13px] font-bold font-mono">{label.charAt(0)}</span>}
|
|
55
|
+
</span>
|
|
56
|
+
{isExpanded && <span className="whitespace-nowrap">{label}</span>}
|
|
57
|
+
</button>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// SidebarMenu
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
export const SidebarMenu = ({ config, isExpanded, activeId, onItemClick }: SidebarMenuProps) => {
|
|
65
|
+
if (!config.length) return null;
|
|
66
|
+
|
|
67
|
+
const renderItem = (item: MenuItem) => (
|
|
68
|
+
<MenuBtn
|
|
69
|
+
key={item.id}
|
|
70
|
+
label={item.label}
|
|
71
|
+
icon={item.icon}
|
|
72
|
+
isExpanded={isExpanded}
|
|
73
|
+
isActive={item.id === activeId}
|
|
74
|
+
onClick={() => onItemClick?.(item.id)}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const topSections = config.filter((s) => s.type !== "bottom");
|
|
79
|
+
const bottomSections = config.filter((s) => s.type === "bottom");
|
|
80
|
+
|
|
81
|
+
const renderSection = (section: MenuSection) => {
|
|
82
|
+
const el =
|
|
83
|
+
section.type === "group" ? (
|
|
84
|
+
<div key={section.id} className={cn("flex flex-col gap-0.5 w-full", isExpanded && "mt-2 first:mt-0")}>
|
|
85
|
+
{isExpanded && (
|
|
86
|
+
<span className="text-[9px] uppercase font-bold tracking-widest text-white/25 px-2.5 py-1 select-none">
|
|
87
|
+
{section.label}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
{section.items.map(renderItem)}
|
|
91
|
+
</div>
|
|
92
|
+
) : section.type === "bottom" ? (
|
|
93
|
+
<div key={section.id} className="flex flex-col gap-0.5 w-full">
|
|
94
|
+
{section.items.map(renderItem)}
|
|
95
|
+
</div>
|
|
96
|
+
) : (
|
|
97
|
+
renderItem({ id: section.id, label: section.label, icon: section.icon, link: section.link })
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return section.divider ? (
|
|
101
|
+
<div key={section.id} className="flex flex-col gap-0.5 w-full">
|
|
102
|
+
<div className="h-px bg-white/10 my-1.5 w-full" />
|
|
103
|
+
{el}
|
|
104
|
+
</div>
|
|
105
|
+
) : el;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="flex flex-col justify-between flex-1 w-full h-full overflow-hidden gap-1">
|
|
110
|
+
<div className="flex flex-col gap-0.5 w-full overflow-y-auto flex-1">
|
|
111
|
+
{topSections.map(renderSection)}
|
|
112
|
+
</div>
|
|
113
|
+
{bottomSections.length > 0 && (
|
|
114
|
+
<div className="flex flex-col gap-0.5 w-full shrink-0">
|
|
115
|
+
{bottomSections.map(renderSection)}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
};
|