@saena-io/create 0.1.0 → 0.2.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/dist/index.js +9 -9
- package/package.json +1 -1
- package/template/base/package.json +44 -2
- package/template/base/scripts/ui-update.ts +83 -0
- package/template/base/src/components/ui/accordion.tsx +75 -0
- package/template/base/src/components/ui/alert-dialog.tsx +162 -0
- package/template/base/src/components/ui/alert.tsx +73 -0
- package/template/base/src/components/ui/app-sidebar.tsx +183 -0
- package/template/base/src/components/ui/aspect-ratio.tsx +22 -0
- package/template/base/src/components/ui/asset-input.tsx +211 -0
- package/template/base/src/components/ui/avatar.tsx +91 -0
- package/template/base/src/components/ui/badge.tsx +50 -0
- package/template/base/src/components/ui/breadcrumb.tsx +104 -0
- package/template/base/src/components/ui/button-group.tsx +78 -0
- package/template/base/src/components/ui/button.tsx +56 -0
- package/template/base/src/components/ui/calendar.tsx +205 -0
- package/template/base/src/components/ui/card.tsx +85 -0
- package/template/base/src/components/ui/carousel.tsx +232 -0
- package/template/base/src/components/ui/chart.tsx +337 -0
- package/template/base/src/components/ui/checkbox.tsx +29 -0
- package/template/base/src/components/ui/collapsible.tsx +15 -0
- package/template/base/src/components/ui/combobox.tsx +276 -0
- package/template/base/src/components/ui/command.tsx +190 -0
- package/template/base/src/components/ui/context-menu.tsx +243 -0
- package/template/base/src/components/ui/dialog.tsx +134 -0
- package/template/base/src/components/ui/direction.tsx +4 -0
- package/template/base/src/components/ui/drawer.tsx +120 -0
- package/template/base/src/components/ui/dropdown-menu.tsx +254 -0
- package/template/base/src/components/ui/empty.tsx +94 -0
- package/template/base/src/components/ui/field.tsx +222 -0
- package/template/base/src/components/ui/focal-point-picker.tsx +175 -0
- package/template/base/src/components/ui/hover-card.tsx +46 -0
- package/template/base/src/components/ui/input-group.tsx +149 -0
- package/template/base/src/components/ui/input-otp.tsx +85 -0
- package/template/base/src/components/ui/input.tsx +20 -0
- package/template/base/src/components/ui/item.tsx +188 -0
- package/template/base/src/components/ui/kbd.tsx +26 -0
- package/template/base/src/components/ui/label.tsx +20 -0
- package/template/base/src/components/ui/menubar.tsx +268 -0
- package/template/base/src/components/ui/native-select.tsx +58 -0
- package/template/base/src/components/ui/nav-main.tsx +70 -0
- package/template/base/src/components/ui/nav-projects.tsx +97 -0
- package/template/base/src/components/ui/nav-secondary.tsx +37 -0
- package/template/base/src/components/ui/nav-user.tsx +108 -0
- package/template/base/src/components/ui/navigation-menu.tsx +164 -0
- package/template/base/src/components/ui/pagination.tsx +123 -0
- package/template/base/src/components/ui/popover.tsx +80 -0
- package/template/base/src/components/ui/progress.tsx +66 -0
- package/template/base/src/components/ui/radio-group.tsx +36 -0
- package/template/base/src/components/ui/resizable.tsx +42 -0
- package/template/base/src/components/ui/rich-text/ai-chat-editor.tsx +20 -0
- package/template/base/src/components/ui/rich-text/ai-command.tsx +90 -0
- package/template/base/src/components/ui/rich-text/ai-copilot.tsx +67 -0
- package/template/base/src/components/ui/rich-text/ai-menu.tsx +456 -0
- package/template/base/src/components/ui/rich-text/ai-node.tsx +42 -0
- package/template/base/src/components/ui/rich-text/ai-toolbar-button.tsx +29 -0
- package/template/base/src/components/ui/rich-text/block-draggable.tsx +187 -0
- package/template/base/src/components/ui/rich-text/block-selection.tsx +17 -0
- package/template/base/src/components/ui/rich-text/code-block-node.tsx +204 -0
- package/template/base/src/components/ui/rich-text/codec.ts +63 -0
- package/template/base/src/components/ui/rich-text/extension.ts +53 -0
- package/template/base/src/components/ui/rich-text/ghost-text.tsx +23 -0
- package/template/base/src/components/ui/rich-text/import-export-toolbar.tsx +103 -0
- package/template/base/src/components/ui/rich-text/link.tsx +18 -0
- package/template/base/src/components/ui/rich-text/list-node.tsx +65 -0
- package/template/base/src/components/ui/rich-text/nodes.tsx +44 -0
- package/template/base/src/components/ui/rich-text/plugins.ts +233 -0
- package/template/base/src/components/ui/rich-text/rich-text-editor.tsx +82 -0
- package/template/base/src/components/ui/rich-text/static.tsx +117 -0
- package/template/base/src/components/ui/rich-text/table-node.tsx +934 -0
- package/template/base/src/components/ui/rich-text/table-toolbar.tsx +232 -0
- package/template/base/src/components/ui/rich-text/toggle-node.tsx +36 -0
- package/template/base/src/components/ui/rich-text/toolbar-slots.ts +41 -0
- package/template/base/src/components/ui/rich-text/toolbar.tsx +668 -0
- package/template/base/src/components/ui/rich-text/use-ai-chat.ts +35 -0
- package/template/base/src/components/ui/rich-text/variable-type.ts +4 -0
- package/template/base/src/components/ui/rich-text/variable.tsx +97 -0
- package/template/base/src/components/ui/scroll-area.tsx +49 -0
- package/template/base/src/components/ui/select.tsx +202 -0
- package/template/base/src/components/ui/separator.tsx +19 -0
- package/template/base/src/components/ui/sheet.tsx +126 -0
- package/template/base/src/components/ui/sidebar.tsx +695 -0
- package/template/base/src/components/ui/skeleton.tsx +13 -0
- package/template/base/src/components/ui/slider.tsx +52 -0
- package/template/base/src/components/ui/sonner.tsx +50 -0
- package/template/base/src/components/ui/spinner.tsx +18 -0
- package/template/base/src/components/ui/switch.tsx +30 -0
- package/template/base/src/components/ui/table.tsx +89 -0
- package/template/base/src/components/ui/tabs.tsx +73 -0
- package/template/base/src/components/ui/textarea.tsx +18 -0
- package/template/base/src/components/ui/toggle-group.tsx +85 -0
- package/template/base/src/components/ui/toggle.tsx +45 -0
- package/template/base/src/components/ui/toolbar.tsx +451 -0
- package/template/base/src/components/ui/tooltip.tsx +52 -0
- package/template/base/src/hooks/use-mobile.ts +19 -0
- package/template/base/src/lib/utils.ts +6 -0
- package/template/base/src/routes/__root.tsx +1 -1
- package/template/base/src/server/auth.ts +2 -2
- package/template/base/src/styles/globals.css +230 -0
- package/template/base/vite.config.ts +15 -1
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ArrowDown01Icon } from '@hugeicons/core-free-icons';
|
|
4
|
+
import { HugeiconsIcon } from '@hugeicons/react';
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { Button, buttonVariants } from '@saena-io/ui/components/button';
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuCheckboxItem,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuRadioGroup,
|
|
14
|
+
DropdownMenuRadioItem,
|
|
15
|
+
DropdownMenuSeparator,
|
|
16
|
+
DropdownMenuSub,
|
|
17
|
+
DropdownMenuSubContent,
|
|
18
|
+
DropdownMenuSubTrigger,
|
|
19
|
+
DropdownMenuTrigger,
|
|
20
|
+
} from '@saena-io/ui/components/dropdown-menu';
|
|
21
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
22
|
+
|
|
23
|
+
// A config-driven, responsive toolbar primitive. The host describes groups of items; this component
|
|
24
|
+
// renders each item as a button/menu when there's room, and folds whole groups into a single dropdown
|
|
25
|
+
// (items become menu entries, nested menus become submenus) when the container gets narrow. Folding is
|
|
26
|
+
// pure CSS via container queries — no measurement, no flicker, SSR-safe. Built on Button + DropdownMenu.
|
|
27
|
+
// Editor-agnostic: a rich-text toolbar, a table toolbar, etc. all build the same `ToolbarGroup[]`.
|
|
28
|
+
|
|
29
|
+
type IconSvg = React.ComponentProps<typeof HugeiconsIcon>['icon'];
|
|
30
|
+
|
|
31
|
+
/** Container-query breakpoint at which a group folds into one dropdown (Tailwind `@sm`…`@2xl`). */
|
|
32
|
+
export type ToolbarBreakpoint = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
33
|
+
|
|
34
|
+
/** A single control: a toggle (carries `active`) or an action (no `active`). */
|
|
35
|
+
export interface ToolbarButtonNode {
|
|
36
|
+
kind: 'button';
|
|
37
|
+
id: string;
|
|
38
|
+
label: string;
|
|
39
|
+
icon?: IconSvg;
|
|
40
|
+
/** Custom glyph (e.g. a bullet shape) when a hugeicon doesn't fit. Wins over `icon`. */
|
|
41
|
+
iconNode?: React.ReactNode;
|
|
42
|
+
/** Present ⇒ a toggle (pressed when expanded, checkable when collapsed). Absent ⇒ a one-shot action. */
|
|
43
|
+
active?: boolean;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
/** Render as a destructive entry when collapsed (e.g. "Delete table"). */
|
|
46
|
+
destructive?: boolean;
|
|
47
|
+
onSelect: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A dropdown of child nodes. Expanded → a trigger + menu; collapsed → a submenu. Nests arbitrarily. */
|
|
51
|
+
export interface ToolbarMenuNode {
|
|
52
|
+
kind: 'menu';
|
|
53
|
+
id: string;
|
|
54
|
+
/** The control's stable name — used for the tooltip / accessible name and the folded submenu label. */
|
|
55
|
+
label: string;
|
|
56
|
+
/** `labeled` variant only: the text shown in the trigger (e.g. the current block type). Falls back to
|
|
57
|
+
* `label`. Keeps the accessible name stable ("Turn into") while the trigger displays the selection. */
|
|
58
|
+
valueLabel?: string;
|
|
59
|
+
icon?: IconSvg;
|
|
60
|
+
active?: boolean;
|
|
61
|
+
/** Expanded presentation: icon-only, icon+label, a split button (quick action + chevron), or plain. */
|
|
62
|
+
variant?: 'icon' | 'labeled' | 'split' | 'plain';
|
|
63
|
+
/** Split variant only: the primary (left half) action. */
|
|
64
|
+
onPrimary?: () => void;
|
|
65
|
+
/** Items are mutually exclusive — render as a radio group with a check on the active one. */
|
|
66
|
+
radio?: boolean;
|
|
67
|
+
/** Disable the trigger / submenu (e.g. Cell / Row / Column when the caret isn't in a table). */
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
triggerClassName?: string;
|
|
70
|
+
items: ToolbarNode[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Escape hatch for genuinely custom UI (a link popover, a grid picker). Supplies either/both forms. */
|
|
74
|
+
export interface ToolbarCustomNode {
|
|
75
|
+
kind: 'custom';
|
|
76
|
+
id: string;
|
|
77
|
+
/** Rendered inline in the expanded toolbar. Omit for content that only ever lives inside a menu. */
|
|
78
|
+
expanded?: React.ReactNode;
|
|
79
|
+
/** Rendered as a menu entry when the group folds. Falls back to `expanded` (fine for click-only UI). */
|
|
80
|
+
collapsed?: React.ReactNode;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A divider between items inside a menu. */
|
|
84
|
+
export interface ToolbarSeparatorNode {
|
|
85
|
+
kind: 'separator';
|
|
86
|
+
id: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type ToolbarNode =
|
|
90
|
+
| ToolbarButtonNode
|
|
91
|
+
| ToolbarMenuNode
|
|
92
|
+
| ToolbarCustomNode
|
|
93
|
+
| ToolbarSeparatorNode;
|
|
94
|
+
|
|
95
|
+
export interface ToolbarGroup {
|
|
96
|
+
id: string;
|
|
97
|
+
label: string;
|
|
98
|
+
icon?: IconSvg;
|
|
99
|
+
/** Fold into one dropdown below this container width; omit / "never" to always stay expanded. */
|
|
100
|
+
collapse?: ToolbarBreakpoint | 'never';
|
|
101
|
+
/** Hide the group entirely (display:none) below this container width — for controls that are redundant
|
|
102
|
+
* on small screens (e.g. text styling, which is also in the floating selection toolbar). */
|
|
103
|
+
hideBelow?: ToolbarBreakpoint;
|
|
104
|
+
items: ToolbarNode[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Static class maps so Tailwind's JIT sees the literal classes (it can't read dynamic `@${bp}:` strings).
|
|
108
|
+
const SHOW_EXPANDED: Record<ToolbarBreakpoint, string> = {
|
|
109
|
+
sm: 'hidden @sm:flex',
|
|
110
|
+
md: 'hidden @md:flex',
|
|
111
|
+
lg: 'hidden @lg:flex',
|
|
112
|
+
xl: 'hidden @xl:flex',
|
|
113
|
+
'2xl': 'hidden @2xl:flex',
|
|
114
|
+
};
|
|
115
|
+
const SHOW_COLLAPSED: Record<ToolbarBreakpoint, string> = {
|
|
116
|
+
sm: 'flex @sm:hidden',
|
|
117
|
+
md: 'flex @md:hidden',
|
|
118
|
+
lg: 'flex @lg:hidden',
|
|
119
|
+
xl: 'flex @xl:hidden',
|
|
120
|
+
'2xl': 'flex @2xl:hidden',
|
|
121
|
+
};
|
|
122
|
+
// A separator shows only where BOTH neighbouring groups are still expanded (container ≥ the larger of
|
|
123
|
+
// their two breakpoints), so the dividers vanish as soon as groups fold into dropdowns.
|
|
124
|
+
const SEP_SHOW: Record<ToolbarBreakpoint, string> = {
|
|
125
|
+
sm: 'hidden @sm:block',
|
|
126
|
+
md: 'hidden @md:block',
|
|
127
|
+
lg: 'hidden @lg:block',
|
|
128
|
+
xl: 'hidden @xl:block',
|
|
129
|
+
'2xl': 'hidden @2xl:block',
|
|
130
|
+
};
|
|
131
|
+
// Hide a whole group below a breakpoint; `contents` at/above it so the group's content rejoins the flow.
|
|
132
|
+
const HIDE_BELOW: Record<ToolbarBreakpoint, string> = {
|
|
133
|
+
sm: 'hidden @sm:contents',
|
|
134
|
+
md: 'hidden @md:contents',
|
|
135
|
+
lg: 'hidden @lg:contents',
|
|
136
|
+
xl: 'hidden @xl:contents',
|
|
137
|
+
'2xl': 'hidden @2xl:contents',
|
|
138
|
+
};
|
|
139
|
+
const BP_RANK: Record<string, number> = { never: 0, sm: 1, md: 2, lg: 3, xl: 4, '2xl': 5 };
|
|
140
|
+
const RANK_BP: (ToolbarBreakpoint | 'never')[] = ['never', 'sm', 'md', 'lg', 'xl', '2xl'];
|
|
141
|
+
// The separator shows only where both neighbours are fully present AND expanded — at/above the largest of
|
|
142
|
+
// their collapse + hideBelow breakpoints — so it vanishes when a neighbour folds OR hides.
|
|
143
|
+
function separatorShowBp(
|
|
144
|
+
prev: ToolbarGroup | undefined,
|
|
145
|
+
group: ToolbarGroup,
|
|
146
|
+
): ToolbarBreakpoint | 'never' {
|
|
147
|
+
const ranks = [prev?.collapse, group.collapse, prev?.hideBelow, group.hideBelow].map(
|
|
148
|
+
(c) => BP_RANK[c ?? 'never'] ?? 0,
|
|
149
|
+
);
|
|
150
|
+
return RANK_BP[Math.max(...ranks)] ?? 'never';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function Glyph({
|
|
154
|
+
icon,
|
|
155
|
+
node,
|
|
156
|
+
className,
|
|
157
|
+
}: { icon?: IconSvg; node?: React.ReactNode; className?: string }) {
|
|
158
|
+
if (node) return <>{node}</>;
|
|
159
|
+
if (!icon) return null;
|
|
160
|
+
return <HugeiconsIcon icon={icon} strokeWidth={2} className={className} />;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function Chevron() {
|
|
164
|
+
return (
|
|
165
|
+
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} className="size-3 shrink-0 opacity-60" />
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function Toolbar({
|
|
170
|
+
groups,
|
|
171
|
+
className,
|
|
172
|
+
}: {
|
|
173
|
+
groups: ToolbarGroup[];
|
|
174
|
+
className?: string;
|
|
175
|
+
}) {
|
|
176
|
+
const visible = groups.filter((g) => g.items.length > 0);
|
|
177
|
+
return (
|
|
178
|
+
<div className={cn('@container flex flex-wrap items-center border-b px-2 py-1', className)}>
|
|
179
|
+
{visible.map((group, i) => (
|
|
180
|
+
<React.Fragment key={group.id}>
|
|
181
|
+
{i > 0 ? <ToolbarSeparator bp={separatorShowBp(visible[i - 1], group)} /> : null}
|
|
182
|
+
<ToolbarGroupView group={group} />
|
|
183
|
+
</React.Fragment>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ToolbarSeparator({ bp }: { bp: ToolbarBreakpoint | 'never' }) {
|
|
190
|
+
return (
|
|
191
|
+
<span
|
|
192
|
+
className={cn('mx-1.5 h-6 w-px shrink-0 bg-border', bp === 'never' ? 'block' : SEP_SHOW[bp])}
|
|
193
|
+
aria-hidden
|
|
194
|
+
/>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function ToolbarGroupView({ group }: { group: ToolbarGroup }) {
|
|
199
|
+
const inner =
|
|
200
|
+
!group.collapse || group.collapse === 'never' ? (
|
|
201
|
+
<div className="flex items-center">
|
|
202
|
+
{group.items.map((n) => (
|
|
203
|
+
<ExpandedNode key={n.id} node={n} />
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
) : (
|
|
207
|
+
// Render both forms; container queries show exactly one based on the toolbar's own width.
|
|
208
|
+
<>
|
|
209
|
+
<div className={cn('items-center', SHOW_EXPANDED[group.collapse])}>
|
|
210
|
+
{group.items.map((n) => (
|
|
211
|
+
<ExpandedNode key={n.id} node={n} />
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
<div className={cn('items-center', SHOW_COLLAPSED[group.collapse])}>
|
|
215
|
+
<CollapsedGroup group={group} />
|
|
216
|
+
</div>
|
|
217
|
+
</>
|
|
218
|
+
);
|
|
219
|
+
// A hidden group is display:none below its breakpoint, `contents` above so its content rejoins the row.
|
|
220
|
+
if (group.hideBelow) return <div className={HIDE_BELOW[group.hideBelow]}>{inner}</div>;
|
|
221
|
+
return inner;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The whole group folded into one dropdown. */
|
|
225
|
+
function CollapsedGroup({ group }: { group: ToolbarGroup }) {
|
|
226
|
+
return (
|
|
227
|
+
<DropdownMenu>
|
|
228
|
+
<DropdownMenuTrigger
|
|
229
|
+
className={cn(buttonVariants({ variant: 'ghost', size: 'default' }))}
|
|
230
|
+
title={group.label}
|
|
231
|
+
aria-label={group.label}
|
|
232
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
233
|
+
>
|
|
234
|
+
{group.icon ? (
|
|
235
|
+
<Glyph icon={group.icon} className="size-3.5 shrink-0" />
|
|
236
|
+
) : (
|
|
237
|
+
<span className="text-xs">{group.label}</span>
|
|
238
|
+
)}
|
|
239
|
+
<Chevron />
|
|
240
|
+
</DropdownMenuTrigger>
|
|
241
|
+
<DropdownMenuContent align="start" className="w-auto">
|
|
242
|
+
{group.items.map((n) => (
|
|
243
|
+
<CollapsedNode key={n.id} node={n} />
|
|
244
|
+
))}
|
|
245
|
+
</DropdownMenuContent>
|
|
246
|
+
</DropdownMenu>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A node rendered inline in the expanded toolbar. */
|
|
251
|
+
function ExpandedNode({ node }: { node: ToolbarNode }) {
|
|
252
|
+
if (node.kind === 'separator') return null; // separators only divide items inside a menu
|
|
253
|
+
if (node.kind === 'custom') return <>{node.expanded}</>;
|
|
254
|
+
if (node.kind === 'button') {
|
|
255
|
+
return (
|
|
256
|
+
<Button
|
|
257
|
+
type="button"
|
|
258
|
+
size="icon"
|
|
259
|
+
variant={node.active ? 'secondary' : 'ghost'}
|
|
260
|
+
aria-pressed={node.active}
|
|
261
|
+
disabled={node.disabled}
|
|
262
|
+
title={node.label}
|
|
263
|
+
aria-label={node.label}
|
|
264
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
265
|
+
onClick={node.onSelect}
|
|
266
|
+
>
|
|
267
|
+
{node.icon || node.iconNode ? (
|
|
268
|
+
<Glyph icon={node.icon} node={node.iconNode} className="size-3.5" />
|
|
269
|
+
) : (
|
|
270
|
+
<span className="text-xs">{node.label}</span>
|
|
271
|
+
)}
|
|
272
|
+
</Button>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return <ExpandedMenu node={node} />;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ExpandedMenu({ node }: { node: ToolbarMenuNode }) {
|
|
279
|
+
const variant = node.variant ?? 'plain';
|
|
280
|
+
const triggerVariant = node.active ? 'secondary' : 'ghost';
|
|
281
|
+
|
|
282
|
+
if (variant === 'split') {
|
|
283
|
+
return (
|
|
284
|
+
<div className="inline-flex items-stretch">
|
|
285
|
+
<Button
|
|
286
|
+
type="button"
|
|
287
|
+
size="icon"
|
|
288
|
+
variant={triggerVariant}
|
|
289
|
+
aria-pressed={node.active}
|
|
290
|
+
disabled={node.disabled}
|
|
291
|
+
className="rounded-r-none"
|
|
292
|
+
title={node.label}
|
|
293
|
+
aria-label={node.label}
|
|
294
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
295
|
+
onClick={node.onPrimary}
|
|
296
|
+
>
|
|
297
|
+
<Glyph icon={node.icon} node={undefined} className="size-3.5" />
|
|
298
|
+
</Button>
|
|
299
|
+
<DropdownMenu>
|
|
300
|
+
<DropdownMenuTrigger
|
|
301
|
+
className={cn(
|
|
302
|
+
buttonVariants({ variant: triggerVariant, size: 'icon' }),
|
|
303
|
+
'w-5 rounded-l-none px-0',
|
|
304
|
+
)}
|
|
305
|
+
title={`${node.label} options`}
|
|
306
|
+
aria-label={`${node.label} options`}
|
|
307
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
308
|
+
>
|
|
309
|
+
<Chevron />
|
|
310
|
+
</DropdownMenuTrigger>
|
|
311
|
+
<DropdownMenuContent align="start" className="w-auto">
|
|
312
|
+
<MenuItems node={node} />
|
|
313
|
+
</DropdownMenuContent>
|
|
314
|
+
</DropdownMenu>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<DropdownMenu>
|
|
321
|
+
<DropdownMenuTrigger
|
|
322
|
+
className={cn(
|
|
323
|
+
buttonVariants({ variant: triggerVariant, size: 'default' }),
|
|
324
|
+
node.triggerClassName,
|
|
325
|
+
)}
|
|
326
|
+
title={node.label}
|
|
327
|
+
aria-label={node.label}
|
|
328
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
329
|
+
>
|
|
330
|
+
<Glyph icon={node.icon} className="size-3.5 shrink-0" />
|
|
331
|
+
{variant === 'labeled' ? (
|
|
332
|
+
<span className="flex-1 truncate text-left text-xs">{node.valueLabel ?? node.label}</span>
|
|
333
|
+
) : null}
|
|
334
|
+
<Chevron />
|
|
335
|
+
</DropdownMenuTrigger>
|
|
336
|
+
<DropdownMenuContent align="start" className="w-auto">
|
|
337
|
+
<MenuItems node={node} />
|
|
338
|
+
</DropdownMenuContent>
|
|
339
|
+
</DropdownMenu>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A menu's children, rendered as dropdown content (radio group when the menu is mutually exclusive). */
|
|
344
|
+
function MenuItems({ node }: { node: ToolbarMenuNode }) {
|
|
345
|
+
if (node.radio) {
|
|
346
|
+
const activeId =
|
|
347
|
+
node.items.find((it) => (it.kind === 'button' || it.kind === 'menu') && it.active)?.id ?? '';
|
|
348
|
+
return (
|
|
349
|
+
<DropdownMenuRadioGroup
|
|
350
|
+
value={activeId}
|
|
351
|
+
onValueChange={(val) => {
|
|
352
|
+
const it = node.items.find((x) => x.id === val);
|
|
353
|
+
if (it && it.kind === 'button') it.onSelect();
|
|
354
|
+
else if (it && it.kind === 'menu') it.onPrimary?.();
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
{node.items.map((it) =>
|
|
358
|
+
it.kind === 'button' ? (
|
|
359
|
+
<DropdownMenuRadioItem
|
|
360
|
+
key={it.id}
|
|
361
|
+
value={it.id}
|
|
362
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
363
|
+
>
|
|
364
|
+
{it.icon || it.iconNode ? (
|
|
365
|
+
<Glyph icon={it.icon} node={it.iconNode} className="size-4" />
|
|
366
|
+
) : null}
|
|
367
|
+
{it.label}
|
|
368
|
+
</DropdownMenuRadioItem>
|
|
369
|
+
) : (
|
|
370
|
+
<CollapsedNode key={it.id} node={it} />
|
|
371
|
+
),
|
|
372
|
+
)}
|
|
373
|
+
</DropdownMenuRadioGroup>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return (
|
|
377
|
+
<>
|
|
378
|
+
{node.items.map((it) => (
|
|
379
|
+
<CollapsedNode key={it.id} node={it} />
|
|
380
|
+
))}
|
|
381
|
+
</>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** A node rendered as a menu entry — inside a collapsed group or inside another menu. */
|
|
386
|
+
function CollapsedNode({ node }: { node: ToolbarNode }) {
|
|
387
|
+
if (node.kind === 'separator') return <DropdownMenuSeparator />;
|
|
388
|
+
if (node.kind === 'custom') return <>{node.collapsed ?? node.expanded}</>;
|
|
389
|
+
|
|
390
|
+
if (node.kind === 'button') {
|
|
391
|
+
if (node.active !== undefined) {
|
|
392
|
+
return (
|
|
393
|
+
<DropdownMenuCheckboxItem
|
|
394
|
+
checked={node.active}
|
|
395
|
+
disabled={node.disabled}
|
|
396
|
+
onCheckedChange={() => node.onSelect()}
|
|
397
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
398
|
+
>
|
|
399
|
+
{node.icon || node.iconNode ? (
|
|
400
|
+
<Glyph icon={node.icon} node={node.iconNode} className="size-4" />
|
|
401
|
+
) : null}
|
|
402
|
+
{node.label}
|
|
403
|
+
</DropdownMenuCheckboxItem>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return (
|
|
407
|
+
<DropdownMenuItem
|
|
408
|
+
variant={node.destructive ? 'destructive' : 'default'}
|
|
409
|
+
disabled={node.disabled}
|
|
410
|
+
onClick={node.onSelect}
|
|
411
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
412
|
+
>
|
|
413
|
+
{node.icon || node.iconNode ? (
|
|
414
|
+
<Glyph icon={node.icon} node={node.iconNode} className="size-4" />
|
|
415
|
+
) : null}
|
|
416
|
+
{node.label}
|
|
417
|
+
</DropdownMenuItem>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// menu → submenu (this is the arbitrary nesting: a folded group can hold submenus of submenus)
|
|
422
|
+
return (
|
|
423
|
+
<DropdownMenuSub>
|
|
424
|
+
<DropdownMenuSubTrigger disabled={node.disabled}>
|
|
425
|
+
{node.icon ? <Glyph icon={node.icon} className="size-4" /> : null}
|
|
426
|
+
{node.label}
|
|
427
|
+
</DropdownMenuSubTrigger>
|
|
428
|
+
<DropdownMenuSubContent className="w-auto">
|
|
429
|
+
<MenuItems node={node} />
|
|
430
|
+
</DropdownMenuSubContent>
|
|
431
|
+
</DropdownMenuSub>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Render a flat list of nodes as expanded controls — no group chrome, collapse, or container query. For
|
|
436
|
+
* compact surfaces like a floating selection toolbar (where an `@container` would break shrink-to-fit). */
|
|
437
|
+
export function ToolbarItems({
|
|
438
|
+
items,
|
|
439
|
+
className,
|
|
440
|
+
}: {
|
|
441
|
+
items: ToolbarNode[];
|
|
442
|
+
className?: string;
|
|
443
|
+
}) {
|
|
444
|
+
return (
|
|
445
|
+
<div className={cn('flex items-center', className)}>
|
|
446
|
+
{items.map((n) => (
|
|
447
|
+
<ExpandedNode key={n.id} node={n} />
|
|
448
|
+
))}
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
4
|
+
|
|
5
|
+
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
|
|
6
|
+
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
|
10
|
+
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
|
14
|
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function TooltipContent({
|
|
18
|
+
className,
|
|
19
|
+
side = 'top',
|
|
20
|
+
sideOffset = 4,
|
|
21
|
+
align = 'center',
|
|
22
|
+
alignOffset = 0,
|
|
23
|
+
children,
|
|
24
|
+
...props
|
|
25
|
+
}: TooltipPrimitive.Popup.Props &
|
|
26
|
+
Pick<TooltipPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
|
|
27
|
+
return (
|
|
28
|
+
<TooltipPrimitive.Portal>
|
|
29
|
+
<TooltipPrimitive.Positioner
|
|
30
|
+
align={align}
|
|
31
|
+
alignOffset={alignOffset}
|
|
32
|
+
side={side}
|
|
33
|
+
sideOffset={sideOffset}
|
|
34
|
+
className="isolate z-50"
|
|
35
|
+
>
|
|
36
|
+
<TooltipPrimitive.Popup
|
|
37
|
+
data-slot="tooltip-content"
|
|
38
|
+
className={cn(
|
|
39
|
+
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
|
46
|
+
</TooltipPrimitive.Popup>
|
|
47
|
+
</TooltipPrimitive.Positioner>
|
|
48
|
+
</TooltipPrimitive.Portal>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768;
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
10
|
+
const onChange = () => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
12
|
+
};
|
|
13
|
+
mql.addEventListener('change', onChange);
|
|
14
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
15
|
+
return () => mql.removeEventListener('change', onChange);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return !!isMobile;
|
|
19
|
+
}
|
|
@@ -3,8 +3,8 @@ import type { Actor } from '@saena-io/plugin-sdk';
|
|
|
3
3
|
import { getCoreDb } from './core';
|
|
4
4
|
|
|
5
5
|
const SECRET = process.env.BETTER_AUTH_SECRET ?? 'dev-secret-change-me-0123456789abcdef';
|
|
6
|
-
// Must match the
|
|
7
|
-
const BASE_URL = process.env.BETTER_AUTH_URL ?? 'http://localhost:
|
|
6
|
+
// Must match the app's origin (PORT) so better-auth's origin/CSRF check passes.
|
|
7
|
+
const BASE_URL = process.env.BETTER_AUTH_URL ?? 'http://localhost:3000';
|
|
8
8
|
|
|
9
9
|
// Server-only: the staff (admin) better-auth audience (§10). The customer audience moved to @saena-io/customers
|
|
10
10
|
// (roadmap M0); a logged-in storefront wires its instance (createCustomerAuth) post-launch. Lazy so a build
|