@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.
@@ -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 }}
@@ -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
- select: {
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.name,
154
- email: ur.user.email,
155
- image: ur.user.image,
156
- isActive: ur.user.isActive,
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
  }));
@@ -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
@@ -15,3 +15,4 @@ export * from "./auth";
15
15
  // export * from "./crud"
16
16
  export * from "./management";
17
17
  export * from "./pages/not-found";
18
+ export * from "./shared";
@@ -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
- const sidebarCollapsibleOptions: SidebarCollapsibleType[] = [
52
- "offcanvas",
53
- "icon",
54
- "none",
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
- <div className="space-y-1.5">
250
- <span className="text-sm">{t("layout")}</span>
251
- <div className="grid grid-cols-2 gap-2">
252
- <Button
253
- variant={
254
- settings.layout === "horizontal"
255
- ? "secondary"
256
- : "outline"
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
- // Generate unique gradient background for each tab based on path
89
- const getTabBackgroundColor = (path: string, isActive: boolean) => {
90
- if (isActive) return "bg-background";
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
- "hover:brightness-105 cursor-pointer",
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-white hover:text-white border-b border-white/10 rounded-t-md"),
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-white hover:text-white border-b border-white/10 rounded-t-md"),
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-white/10",
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
- // Enforce icon mode for Carbon style consistency if not set explicitly to offcanvas by some override (though we prefer icon)
189
- const collapsibleMode =
190
- settings.sidebarCollapsible === "offcanvas"
191
- ? "icon"
192
- : settings.sidebarCollapsible || "icon";
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
@@ -132,3 +132,4 @@ export * from "./toggle";
132
132
  export * from "./toggle-group";
133
133
  export * from "./combobox";
134
134
  export * from "./label";
135
+ export * from "./dynamic-icon";
@@ -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-sidebar text-sidebar-foreground",
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-2.5"
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
- "group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow",
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
- "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
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
+ });