@goplusvn/core 0.1.12 → 0.1.14
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/CHANGELOG.md +83 -0
- package/package.json +3 -1
- package/src/auth/proxy-gate.ts +80 -0
- package/src/crud/crud-route-handlers.ts +157 -0
- package/src/crud/server-service.ts +312 -0
- package/src/crud/server.ts +18 -0
- package/src/providers/index.tsx +19 -0
- package/src/rbac/role-service.ts +40 -33
- package/src/styles/base.css +41 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/customizer.tsx +12 -42
- package/src/ui/layout/page-tabs.tsx +8 -42
- package/src/ui/layout/sidebar.tsx +7 -5
- package/src/ui/primitives/index.tsx +1 -0
- package/src/ui/primitives/sidebar.tsx +25 -4
- package/src/ui/shared/index.ts +6 -0
- package/src/ui/shared/page-header.tsx +57 -0
- package/src/ui/shared/status-indicator.tsx +173 -0
- package/src/ui/shared/table-styles.ts +47 -0
- package/src/ui/shared/table-sum-footer.tsx +41 -0
package/src/providers/index.tsx
CHANGED
|
@@ -76,6 +76,25 @@ export function SettingsProvider({
|
|
|
76
76
|
setSettings(defaultSettings);
|
|
77
77
|
}, [deleteStoredSettings]);
|
|
78
78
|
|
|
79
|
+
// Apply the appearance choices the Customizer exposes but that nothing else
|
|
80
|
+
// wires up. Without this, the "Radius" and "Density" controls store a value
|
|
81
|
+
// yet change nothing on screen (dead controls in every app using core).
|
|
82
|
+
// - radius → drive the real `--radius` token. Tailwind's @theme inline
|
|
83
|
+
// derives rounded-sm/md/lg/xl from it (see styles/base.css), so the whole
|
|
84
|
+
// UI re-rounds live. The Customizer stores rem (0, 0.3, 0.5, 0.75, 1).
|
|
85
|
+
// - density → expose as a `data-density` attribute; base.css tightens the
|
|
86
|
+
// global `--spacing` scale in compact mode ([data-density="compact"]).
|
|
87
|
+
// Client-only (useEffect) so there's no SSR/hydration mismatch.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const root = document.documentElement;
|
|
90
|
+
root.style.setProperty("--radius", `${settings.radius ?? 0.5}rem`);
|
|
91
|
+
if (settings.density === "compact") {
|
|
92
|
+
root.setAttribute("data-density", "compact");
|
|
93
|
+
} else {
|
|
94
|
+
root.removeAttribute("data-density");
|
|
95
|
+
}
|
|
96
|
+
}, [settings.radius, settings.density]);
|
|
97
|
+
|
|
79
98
|
return (
|
|
80
99
|
<SettingsContext.Provider
|
|
81
100
|
value={{ settings, updateSettings, resetSettings }}
|
package/src/rbac/role-service.ts
CHANGED
|
@@ -18,6 +18,23 @@ export type RoleFilters = {
|
|
|
18
18
|
status?: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// Schema-tolerance seam: apps whose User/Role tables diverge from the default
|
|
22
|
+
// (name/email/image/isActive + Role.createdAt/updatedAt) pass field overrides so
|
|
23
|
+
// getRolesData works without forking. E.g. wu-vpbank:
|
|
24
|
+
// { userNameField: "fullName", userActiveField: "active", userImageField: null, roleTimestamps: false }
|
|
25
|
+
export type RoleServiceSchema = {
|
|
26
|
+
/** User display-name column. Default "name". */
|
|
27
|
+
userNameField?: string;
|
|
28
|
+
/** User email column. Default "email". */
|
|
29
|
+
userEmailField?: string;
|
|
30
|
+
/** User avatar column, or null if the table has none. Default "image". */
|
|
31
|
+
userImageField?: string | null;
|
|
32
|
+
/** User active-flag column. Default "isActive". */
|
|
33
|
+
userActiveField?: string;
|
|
34
|
+
/** Whether Role has createdAt/updatedAt. Default true; false → order by name, blank timestamps. */
|
|
35
|
+
roleTimestamps?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
21
38
|
export type RoleData = {
|
|
22
39
|
id: string;
|
|
23
40
|
name: string;
|
|
@@ -68,6 +85,7 @@ export interface RolePrismaClient {
|
|
|
68
85
|
export async function getRolesData(
|
|
69
86
|
db: RolePrismaClient,
|
|
70
87
|
params: RoleFilters = {},
|
|
88
|
+
schema: RoleServiceSchema = {},
|
|
71
89
|
): Promise<{
|
|
72
90
|
total: number;
|
|
73
91
|
page: number;
|
|
@@ -76,6 +94,13 @@ export async function getRolesData(
|
|
|
76
94
|
}> {
|
|
77
95
|
const { page = 1, pageSize = 10, search, status } = params;
|
|
78
96
|
|
|
97
|
+
// Schema knobs (default to the canonical name/email/image/isActive + timestamps).
|
|
98
|
+
const nameField = schema.userNameField ?? "name";
|
|
99
|
+
const emailField = schema.userEmailField ?? "email";
|
|
100
|
+
const imageField = schema.userImageField === undefined ? "image" : schema.userImageField;
|
|
101
|
+
const activeField = schema.userActiveField ?? "isActive";
|
|
102
|
+
const hasTimestamps = schema.roleTimestamps ?? true;
|
|
103
|
+
|
|
79
104
|
const whereConditions: any[] = [];
|
|
80
105
|
|
|
81
106
|
if (search) {
|
|
@@ -94,42 +119,24 @@ export async function getRolesData(
|
|
|
94
119
|
|
|
95
120
|
const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
|
|
96
121
|
|
|
122
|
+
// Build the user select from the (possibly overridden) field names.
|
|
123
|
+
const userSelect: Record<string, boolean> = { id: true, [nameField]: true, [emailField]: true };
|
|
124
|
+
if (imageField) userSelect[imageField] = true;
|
|
125
|
+
if (activeField) userSelect[activeField] = true;
|
|
126
|
+
|
|
97
127
|
const [total, items] = await Promise.all([
|
|
98
128
|
db.role.count({ where }),
|
|
99
129
|
db.role.findMany({
|
|
100
130
|
where,
|
|
101
|
-
orderBy: { createdAt: "desc" },
|
|
131
|
+
orderBy: hasTimestamps ? { createdAt: "desc" } : { name: "asc" },
|
|
102
132
|
skip: (page - 1) * pageSize,
|
|
103
133
|
take: pageSize,
|
|
104
134
|
include: {
|
|
105
|
-
userRoles: {
|
|
106
|
-
include: {
|
|
107
|
-
user: {
|
|
108
|
-
select: {
|
|
109
|
-
id: true,
|
|
110
|
-
name: true,
|
|
111
|
-
email: true,
|
|
112
|
-
image: true,
|
|
113
|
-
isActive: true,
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
},
|
|
135
|
+
userRoles: { include: { user: { select: userSelect } } },
|
|
118
136
|
rolePermissions: {
|
|
119
137
|
include: {
|
|
120
|
-
resource: {
|
|
121
|
-
|
|
122
|
-
code: true,
|
|
123
|
-
name: true,
|
|
124
|
-
icon: true,
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
action: {
|
|
128
|
-
select: {
|
|
129
|
-
code: true,
|
|
130
|
-
name: true,
|
|
131
|
-
},
|
|
132
|
-
},
|
|
138
|
+
resource: { select: { code: true, name: true, icon: true } },
|
|
139
|
+
action: { select: { code: true, name: true } },
|
|
133
140
|
},
|
|
134
141
|
},
|
|
135
142
|
},
|
|
@@ -150,13 +157,13 @@ export async function getRolesData(
|
|
|
150
157
|
usersCount: role.userRoles.length,
|
|
151
158
|
users: role.userRoles.map((ur: any) => ({
|
|
152
159
|
id: ur.user.id,
|
|
153
|
-
name: ur.user
|
|
154
|
-
email: ur.user
|
|
155
|
-
image: ur.user
|
|
156
|
-
isActive: ur.user
|
|
160
|
+
name: ur.user[nameField] ?? null,
|
|
161
|
+
email: ur.user[emailField] ?? null,
|
|
162
|
+
image: imageField ? ur.user[imageField] ?? null : null,
|
|
163
|
+
isActive: activeField ? Boolean(ur.user[activeField]) : true,
|
|
157
164
|
})),
|
|
158
|
-
createdAt: role.createdAt.toISOString(),
|
|
159
|
-
updatedAt: role.updatedAt.toISOString(),
|
|
165
|
+
createdAt: hasTimestamps && role.createdAt ? role.createdAt.toISOString() : "",
|
|
166
|
+
updatedAt: hasTimestamps && role.updatedAt ? role.updatedAt.toISOString() : "",
|
|
160
167
|
createdBy: role.createdBy || undefined,
|
|
161
168
|
updatedBy: role.updatedBy || undefined,
|
|
162
169
|
}));
|
package/src/styles/base.css
CHANGED
|
@@ -582,4 +582,45 @@ aside.EmojiPickerReact {
|
|
|
582
582
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
583
583
|
background: #64748b;
|
|
584
584
|
/* slate-500 */
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* ============================================================================
|
|
588
|
+
* Customizer: Density ("Comfortable" vs "Compact")
|
|
589
|
+
* SettingsProvider sets data-density="compact" on <html> from settings.density.
|
|
590
|
+
* Tailwind v4 derives EVERY spacing utility (p-/px-/gap-/space-/h-/w-/size-…)
|
|
591
|
+
* from the single `--spacing` token (default 0.25rem). Tightening it here packs
|
|
592
|
+
* the whole UI from one lever — tables, forms, cards, the sidebar — instead of
|
|
593
|
+
* per-component edits. 0.215rem ≈ 14% tighter: denser, still comfortably hit.
|
|
594
|
+
* ========================================================================== */
|
|
595
|
+
[data-density="compact"] {
|
|
596
|
+
--spacing: 0.215rem;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* ============================================================================
|
|
600
|
+
* Customizer: Sidebar variant "inset" / "floating" — frame colour.
|
|
601
|
+
* Core's default sidebar is a deep navy (--sidebar-background), and the shadcn
|
|
602
|
+
* inset/floating treatment paints the page frame with that same sidebar colour
|
|
603
|
+
* (has-[[data-variant=inset]]:bg-sidebar). On the navy default that reads as a
|
|
604
|
+
* harsh, broken-looking block. Repaint the frame with the soft neutral so the
|
|
605
|
+
* content reads as a CARD FLOATING ON A CALM PAGE (the rounded-xl + shadow core
|
|
606
|
+
* already applies do the floating). Token-based → adapts to dark mode.
|
|
607
|
+
* Un-layered → wins over the Tailwind `bg-sidebar` utility (in @layer utilities)
|
|
608
|
+
* regardless of specificity.
|
|
609
|
+
* ========================================================================== */
|
|
610
|
+
.group\/sidebar-wrapper:has([data-variant="inset"]),
|
|
611
|
+
.group\/sidebar-wrapper:has([data-variant="floating"]) {
|
|
612
|
+
background-color: hsl(var(--muted));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/* ============================================================================
|
|
616
|
+
* Customizer: floating / inset — round the header's BOTTOM-left corner.
|
|
617
|
+
* The content panel already rounds its left corners (rounded-l-xl clips the
|
|
618
|
+
* sticky header's top-left) and the sidebar rounds its corners, but the
|
|
619
|
+
* header's divider still met the left edge as a hard inner corner. The layout
|
|
620
|
+
* header is the first child of the SidebarInset <main>, a sibling after the
|
|
621
|
+
* sidebar peer (which carries data-variant). 0.75rem = rounded-xl, matching.
|
|
622
|
+
* ========================================================================== */
|
|
623
|
+
[data-variant="floating"] ~ main > header,
|
|
624
|
+
[data-variant="inset"] ~ main > header {
|
|
625
|
+
border-bottom-left-radius: 0.75rem;
|
|
585
626
|
}
|
package/src/ui/index.tsx
CHANGED
|
@@ -5,8 +5,6 @@ import { useParams, usePathname, useRouter } from "next/navigation";
|
|
|
5
5
|
import {
|
|
6
6
|
AlignLeft,
|
|
7
7
|
AlignRight,
|
|
8
|
-
AlignStartHorizontal,
|
|
9
|
-
AlignStartVertical,
|
|
10
8
|
MoonStar,
|
|
11
9
|
RotateCcw,
|
|
12
10
|
Sun,
|
|
@@ -48,11 +46,10 @@ interface CustomizerProps {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
const sidebarVariants: SidebarVariantType[] = ["sidebar", "floating", "inset"];
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
];
|
|
49
|
+
// "none" (lock the sidebar expanded) is intentionally NOT offered: it makes the
|
|
50
|
+
// header collapse-toggle a no-op, which reads as a broken/conflicting control.
|
|
51
|
+
// Both remaining options work with that toggle — offcanvas hides, icon → rail.
|
|
52
|
+
const sidebarCollapsibleOptions: SidebarCollapsibleType[] = ["offcanvas", "icon"];
|
|
56
53
|
const densityOptions: DensityType[] = ["comfortable", "compact"];
|
|
57
54
|
|
|
58
55
|
// Localized labels — the customizer follows the active URL locale (params.lang)
|
|
@@ -246,41 +243,14 @@ export function Customizer({ trigger, triggerClassName }: CustomizerProps) {
|
|
|
246
243
|
<SunMoon className="shrink-0 h-4 w-4" />
|
|
247
244
|
</Button>
|
|
248
245
|
</div>
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
onClick={() =>
|
|
259
|
-
updateSettings({
|
|
260
|
-
...settings,
|
|
261
|
-
layout: "horizontal",
|
|
262
|
-
})
|
|
263
|
-
}
|
|
264
|
-
>
|
|
265
|
-
<AlignStartHorizontal className="shrink-0 h-4 w-4 me-2" />
|
|
266
|
-
{t("horizontal")}
|
|
267
|
-
</Button>
|
|
268
|
-
<Button
|
|
269
|
-
variant={
|
|
270
|
-
settings.layout === "vertical" ? "secondary" : "outline"
|
|
271
|
-
}
|
|
272
|
-
onClick={() =>
|
|
273
|
-
updateSettings({
|
|
274
|
-
...settings,
|
|
275
|
-
layout: "vertical",
|
|
276
|
-
})
|
|
277
|
-
}
|
|
278
|
-
>
|
|
279
|
-
<AlignStartVertical className="shrink-0 h-4 w-4 me-2" />
|
|
280
|
-
{t("vertical")}
|
|
281
|
-
</Button>
|
|
282
|
-
</div>
|
|
283
|
-
</div>
|
|
246
|
+
{/* "Bố cục" (layout: horizontal/vertical) removed from the
|
|
247
|
+
picker. The horizontal layout swaps the whole nav for a top
|
|
248
|
+
menubar, which makes every sidebar option below ("Kiểu thanh
|
|
249
|
+
bên", "Thu gọn thanh bên") a no-op — dead/conflicting
|
|
250
|
+
controls. The apps here are designed around the vertical
|
|
251
|
+
sidebar, so the customizer locks to it (defaultSettings.layout
|
|
252
|
+
stays "vertical"; the HorizontalLayout code is untouched for
|
|
253
|
+
anything that sets layout directly). */}
|
|
284
254
|
|
|
285
255
|
<div className="space-y-1.5">
|
|
286
256
|
<span className="text-sm">{t("sidebarVariant")}</span>
|
|
@@ -85,44 +85,10 @@ export function PageTabs({
|
|
|
85
85
|
currentTabIndex >= 0 && currentTabIndex < sortedTabs.length - 1;
|
|
86
86
|
const hasOtherTabs = sortedTabs.length > 1;
|
|
87
87
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Generate consistent hash from path
|
|
93
|
-
const normalizedPath = path.replace(/^\/[a-z]{2}(\/|$)/, "/");
|
|
94
|
-
const hash = normalizedPath.split("").reduce((acc, char) => {
|
|
95
|
-
return (acc << 5) - acc + char.charCodeAt(0);
|
|
96
|
-
}, 0);
|
|
97
|
-
|
|
98
|
-
// Refined gradient palette - darker and more visible (using dark theme colors for both modes)
|
|
99
|
-
const gradients = [
|
|
100
|
-
"bg-gradient-to-r from-slate-700 to-slate-600",
|
|
101
|
-
"bg-gradient-to-r from-zinc-700 to-zinc-600",
|
|
102
|
-
"bg-gradient-to-r from-stone-700 to-stone-600",
|
|
103
|
-
"bg-gradient-to-r from-neutral-700 to-neutral-600",
|
|
104
|
-
"bg-gradient-to-r from-blue-700 to-blue-600",
|
|
105
|
-
"bg-gradient-to-r from-indigo-700 to-indigo-600",
|
|
106
|
-
"bg-gradient-to-r from-purple-700 to-purple-600",
|
|
107
|
-
"bg-gradient-to-r from-violet-700 to-violet-600",
|
|
108
|
-
"bg-gradient-to-r from-fuchsia-700 to-fuchsia-600",
|
|
109
|
-
"bg-gradient-to-r from-pink-700 to-pink-600",
|
|
110
|
-
"bg-gradient-to-r from-rose-700 to-rose-600",
|
|
111
|
-
"bg-gradient-to-r from-red-700 to-red-600",
|
|
112
|
-
"bg-gradient-to-r from-orange-700 to-orange-600",
|
|
113
|
-
"bg-gradient-to-r from-amber-700 to-amber-600",
|
|
114
|
-
"bg-gradient-to-r from-yellow-700 to-yellow-600",
|
|
115
|
-
"bg-gradient-to-r from-lime-700 to-lime-600",
|
|
116
|
-
"bg-gradient-to-r from-green-700 to-green-600",
|
|
117
|
-
"bg-gradient-to-r from-emerald-700 to-emerald-600",
|
|
118
|
-
"bg-gradient-to-r from-teal-700 to-teal-600",
|
|
119
|
-
"bg-gradient-to-r from-cyan-700 to-cyan-600",
|
|
120
|
-
"bg-gradient-to-r from-sky-700 to-sky-600",
|
|
121
|
-
];
|
|
122
|
-
|
|
123
|
-
// Select gradient based on hash to ensure consistency
|
|
124
|
-
const gradientIndex = Math.abs(hash) % gradients.length;
|
|
125
|
-
return gradients[gradientIndex];
|
|
88
|
+
// Neutral, single-style tab background (was a per-path rainbow gradient).
|
|
89
|
+
// Active tabs sit on the page surface; inactive tabs are a muted chip.
|
|
90
|
+
const getTabBackgroundColor = (_path: string, isActive: boolean) => {
|
|
91
|
+
return isActive ? "bg-background" : "bg-muted/60";
|
|
126
92
|
};
|
|
127
93
|
|
|
128
94
|
return (
|
|
@@ -163,21 +129,21 @@ export function PageTabs({
|
|
|
163
129
|
className={cn(
|
|
164
130
|
"group relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-all duration-200",
|
|
165
131
|
"border border-transparent",
|
|
166
|
-
"
|
|
132
|
+
"cursor-pointer",
|
|
167
133
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
168
134
|
getTabBackgroundColor(tab.path, isActive),
|
|
169
135
|
variant === "default" &&
|
|
170
136
|
(isActive
|
|
171
137
|
? "bg-background text-foreground border-b-2 border-primary shadow-sm shadow-primary/10 border-t border-x border-b-0 rounded-t-md -mb-px z-10"
|
|
172
|
-
: "text-
|
|
138
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground border-b border-border rounded-t-md"),
|
|
173
139
|
variant === "header" &&
|
|
174
140
|
(isActive
|
|
175
141
|
? "bg-background text-foreground border-b-2 border-primary shadow-sm shadow-primary/10 border-t border-x rounded-t-md z-10"
|
|
176
|
-
: "text-
|
|
142
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground border-b border-border rounded-t-md"),
|
|
177
143
|
!isLast &&
|
|
178
144
|
!isActive &&
|
|
179
145
|
variant === "default" &&
|
|
180
|
-
"border-r border-
|
|
146
|
+
"border-r border-border",
|
|
181
147
|
)}
|
|
182
148
|
>
|
|
183
149
|
{/* Tab number indicator (for keyboard shortcuts) */}
|
|
@@ -185,11 +185,13 @@ export function AppSidebar({
|
|
|
185
185
|
}
|
|
186
186
|
};
|
|
187
187
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
188
|
+
// Honour the Customizer's "Thu gọn thanh bên" choice as-is:
|
|
189
|
+
// offcanvas → collapses fully off-screen (hidden), reopened via header toggle
|
|
190
|
+
// icon → collapses to an icon rail
|
|
191
|
+
// none → never collapses (always expanded)
|
|
192
|
+
// Previously offcanvas was force-remapped to icon, so "Ẩn ngoài" could never
|
|
193
|
+
// actually hide the sidebar (it behaved identically to "Biểu tượng").
|
|
194
|
+
const collapsibleMode = settings.sidebarCollapsible || "icon";
|
|
193
195
|
|
|
194
196
|
return (
|
|
195
197
|
<SidebarWrapper
|
|
@@ -218,10 +218,16 @@ const Sidebar = React.forwardRef<
|
|
|
218
218
|
}, [hoverExpandEnabled, setHoverOpen]);
|
|
219
219
|
|
|
220
220
|
if (collapsible === "none") {
|
|
221
|
+
// A non-collapsing sidebar is permanently EXPANDED, so it must use the
|
|
222
|
+
// expanded surface (bg-background / text-foreground), NOT the deep
|
|
223
|
+
// `bg-sidebar` colour. On a coloured sidebar (e.g. the navy default) the
|
|
224
|
+
// old `bg-sidebar text-sidebar-foreground` left the group labels + active
|
|
225
|
+
// item — which are `text-primary` — at ~1.9:1 contrast (unreadable). This
|
|
226
|
+
// matches the expanded state of the collapsible sidebar (labels-on-light).
|
|
221
227
|
return (
|
|
222
228
|
<div
|
|
223
229
|
className={cn(
|
|
224
|
-
"flex h-full w-(--sidebar-width) flex-col bg-
|
|
230
|
+
"flex h-full w-(--sidebar-width) flex-col border-r bg-background text-foreground",
|
|
225
231
|
className,
|
|
226
232
|
)}
|
|
227
233
|
ref={ref}
|
|
@@ -294,7 +300,7 @@ const Sidebar = React.forwardRef<
|
|
|
294
300
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
|
295
301
|
// Adjustments for collapsible=icon
|
|
296
302
|
variant === "floating" || variant === "inset"
|
|
297
|
-
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_8px)] group-data-[collapsible=icon]:px-
|
|
303
|
+
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_8px)] group-data-[collapsible=icon]:px-1"
|
|
298
304
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[collapsible=icon]:border-r group-data-[collapsible=icon]:px-0",
|
|
299
305
|
// Hover expand - override icon width to full width on hover
|
|
300
306
|
"group-data-[hover-expanded=true]:!w-(--sidebar-width) group-data-[hover-expanded=true]:shadow-xl group-data-[hover-expanded=true]:z-[100]",
|
|
@@ -310,7 +316,12 @@ const Sidebar = React.forwardRef<
|
|
|
310
316
|
"group-data-[state=collapsed]:bg-sidebar group-data-[state=collapsed]:border-border/50",
|
|
311
317
|
"group-data-[state=expanded]:bg-background group-data-[state=expanded]:border-border/50",
|
|
312
318
|
"group-data-[hover-expanded=true]:!bg-background group-data-[hover-expanded=true]:!border-border/50",
|
|
313
|
-
|
|
319
|
+
// Floating & inset: the sidebar is a soft floating panel. Round
|
|
320
|
+
// ALL corners (rounded-xl) so the top-right corner where it meets
|
|
321
|
+
// the header reads as a soft curve, not a hard 90° junction. Inset
|
|
322
|
+
// previously had no radius (sharp navy block) — now matches.
|
|
323
|
+
"group-data-[variant=floating]:rounded-xl group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow",
|
|
324
|
+
"group-data-[variant=inset]:rounded-xl group-data-[variant=inset]:border group-data-[variant=inset]:border-sidebar-border group-data-[variant=inset]:shadow",
|
|
314
325
|
)}
|
|
315
326
|
>
|
|
316
327
|
{children}
|
|
@@ -386,7 +397,17 @@ const SidebarInset = React.forwardRef<
|
|
|
386
397
|
ref={ref}
|
|
387
398
|
className={cn(
|
|
388
399
|
"relative flex min-h-svh flex-1 flex-col bg-muted/40 transition-[margin] duration-200 ease-linear",
|
|
389
|
-
|
|
400
|
+
// Inset: content is a card floating with a uniform m-2 gap on ALL sides
|
|
401
|
+
// (no ml-0 override) so there's a small gap between the sidebar and the
|
|
402
|
+
// content/header instead of them touching.
|
|
403
|
+
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
|
404
|
+
// Floating: the content is full-bleed (no card), and its LEFT edge is
|
|
405
|
+
// the side that meets the floating sidebar. Give it a small left gap
|
|
406
|
+
// (ml-2) so it doesn't touch the sidebar, and round both left corners
|
|
407
|
+
// (top-left clips the sticky header, bottom-left clips the footer via
|
|
408
|
+
// overflow-hidden) so the content hugs the floating sidebar with soft
|
|
409
|
+
// curves instead of hard 90° corners. Right edge stays flush.
|
|
410
|
+
"md:peer-data-[variant=floating]:ml-2 md:peer-data-[variant=floating]:rounded-l-xl",
|
|
390
411
|
className,
|
|
391
412
|
)}
|
|
392
413
|
{...props}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @goerp/core/ui shared app-level components (promoted from vinhhoa/wu so apps
|
|
2
|
+
// stop copying them). Re-exported through the ui barrel.
|
|
3
|
+
export * from "./page-header";
|
|
4
|
+
export * from "./status-indicator";
|
|
5
|
+
export * from "./table-sum-footer";
|
|
6
|
+
export * from "./table-styles";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PageHeader — standardized page header (title + description + icon + actions).
|
|
5
|
+
* Promoted from vinhhoa/wu; every app was copying this.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { memo } from "react";
|
|
9
|
+
import { cn } from "../../utils";
|
|
10
|
+
|
|
11
|
+
export interface PageHeaderProps {
|
|
12
|
+
/** Page title (required) */
|
|
13
|
+
title: string;
|
|
14
|
+
/** Optional subtitle/description */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Optional icon displayed before the title */
|
|
17
|
+
icon?: React.ReactNode;
|
|
18
|
+
/** Right-side actions (buttons, filters, etc.) */
|
|
19
|
+
actions?: React.ReactNode;
|
|
20
|
+
/** Breadcrumb node above the title */
|
|
21
|
+
breadcrumbs?: React.ReactNode;
|
|
22
|
+
/** Extra content below the title/actions row */
|
|
23
|
+
children?: React.ReactNode;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PageHeader = memo(function PageHeader({
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
icon,
|
|
31
|
+
actions,
|
|
32
|
+
breadcrumbs,
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
}: PageHeaderProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={cn("space-y-1", className)}>
|
|
38
|
+
{breadcrumbs && <div className="text-xs text-text-tertiary">{breadcrumbs}</div>}
|
|
39
|
+
|
|
40
|
+
<div className="flex items-center justify-between gap-4">
|
|
41
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
42
|
+
{icon && <div className="flex-shrink-0 text-text-secondary">{icon}</div>}
|
|
43
|
+
<div className="min-w-0">
|
|
44
|
+
<h1 className="truncate text-lg font-semibold text-text-primary">{title}</h1>
|
|
45
|
+
{description && (
|
|
46
|
+
<p className="mt-0.5 truncate text-sm text-text-secondary">{description}</p>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{actions && <div className="flex flex-shrink-0 items-center gap-2">{actions}</div>}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{children && <div className="mt-3">{children}</div>}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
});
|