@fayz-ai/ui 0.6.6 → 0.7.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/chunk-75EFDLST.js +274 -0
- package/dist/chunk-75EFDLST.js.map +1 -0
- package/dist/{chunk-YB5ZJKNG.cjs → chunk-7PTRAWHO.cjs} +263 -225
- package/dist/chunk-7PTRAWHO.cjs.map +1 -0
- package/dist/{chunk-LO5QZO7F.js → chunk-COC5DDFG.js} +125 -20
- package/dist/chunk-COC5DDFG.js.map +1 -0
- package/dist/{chunk-MYZT2OG7.js → chunk-FY7OZNKU.js} +24 -68
- package/dist/chunk-FY7OZNKU.js.map +1 -0
- package/dist/chunk-H6OULCHF.cjs +81 -0
- package/dist/chunk-H6OULCHF.cjs.map +1 -0
- package/dist/{chunk-DJHS2XRW.cjs → chunk-HG5RH3B3.cjs} +161 -54
- package/dist/chunk-HG5RH3B3.cjs.map +1 -0
- package/dist/{chunk-5QFB6AIQ.cjs → chunk-NWPMNPGR.cjs} +40 -40
- package/dist/{chunk-5QFB6AIQ.cjs.map → chunk-NWPMNPGR.cjs.map} +1 -1
- package/dist/{chunk-D6SXPF4F.cjs → chunk-ODY3GZ3Y.cjs} +41 -87
- package/dist/chunk-ODY3GZ3Y.cjs.map +1 -0
- package/dist/{chunk-22J2RTMN.js → chunk-OTUH4K4B.js} +196 -150
- package/dist/chunk-OTUH4K4B.js.map +1 -0
- package/dist/chunk-OW73RZOU.cjs +313 -0
- package/dist/chunk-OW73RZOU.cjs.map +1 -0
- package/dist/{chunk-XLBGB7IW.js → chunk-W5O2X277.js} +4 -4
- package/dist/{chunk-XLBGB7IW.js.map → chunk-W5O2X277.js.map} +1 -1
- package/dist/chunk-YRVE4FOD.js +57 -0
- package/dist/chunk-YRVE4FOD.js.map +1 -0
- package/dist/dashboard/index.cjs +27 -27
- package/dist/dashboard/index.js +3 -3
- package/dist/index.cjs +195 -175
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/layout/ContentSplit.d.ts +32 -0
- package/dist/layout/ContentSplit.d.ts.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Topbar.d.ts.map +1 -1
- package/dist/layout/index.cjs +31 -23
- package/dist/layout/index.d.ts +1 -0
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js +3 -3
- package/dist/primitives/index.cjs +126 -114
- package/dist/primitives/index.d.ts +2 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +4 -4
- package/dist/primitives/markdown-editor.d.ts +23 -0
- package/dist/primitives/markdown-editor.d.ts.map +1 -0
- package/dist/primitives/markdown.d.ts +9 -0
- package/dist/primitives/markdown.d.ts.map +1 -0
- package/package.json +6 -3
- package/src/index.ts +9 -0
- package/src/layout/ContentSplit.tsx +153 -0
- package/src/layout/Sidebar.tsx +39 -14
- package/src/layout/Topbar.tsx +2 -1
- package/src/layout/index.ts +6 -0
- package/src/primitives/dropdown.tsx +4 -4
- package/src/primitives/index.ts +2 -0
- package/src/primitives/markdown-editor.tsx +75 -0
- package/src/primitives/markdown.tsx +169 -0
- package/src/primitives/select.tsx +1 -1
- package/dist/chunk-22J2RTMN.js.map +0 -1
- package/dist/chunk-D6SXPF4F.cjs.map +0 -1
- package/dist/chunk-DJHS2XRW.cjs.map +0 -1
- package/dist/chunk-DNYVEOAX.js +0 -11
- package/dist/chunk-DNYVEOAX.js.map +0 -1
- package/dist/chunk-E526FHZ4.js +0 -169
- package/dist/chunk-E526FHZ4.js.map +0 -1
- package/dist/chunk-LO5QZO7F.js.map +0 -1
- package/dist/chunk-MYZT2OG7.js.map +0 -1
- package/dist/chunk-VVVZO2LE.cjs +0 -13
- package/dist/chunk-VVVZO2LE.cjs.map +0 -1
- package/dist/chunk-W4XXXJRW.cjs +0 -196
- package/dist/chunk-W4XXXJRW.cjs.map +0 -1
- package/dist/chunk-YB5ZJKNG.cjs.map +0 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { PanelRight, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
|
3
|
+
import { cn } from '../utils/cn'
|
|
4
|
+
import { Button } from '../primitives/button'
|
|
5
|
+
import {
|
|
6
|
+
Sheet, SheetContent, SheetHeader, SheetBody, SheetTitle, SheetDescription,
|
|
7
|
+
} from '../primitives/sheet'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// ContentSplit — main content with a right-aligned companion panel, mobile
|
|
11
|
+
// first. Below `lg` the aside lives in a right Sheet (opened by a trigger the
|
|
12
|
+
// host places wherever fits — typically SubpageHeader actions, via
|
|
13
|
+
// <ContentSplitTrigger/>). From `lg` up it docks as a sticky right column.
|
|
14
|
+
//
|
|
15
|
+
// Pairs with SubpageHeader/ModulePage: the page keeps its own back-button
|
|
16
|
+
// header; ContentSplit only owns the columns below it. Reuse for any
|
|
17
|
+
// "document + operational panel" page (script + shooting checklist, form +
|
|
18
|
+
// help, detail + activity feed).
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
interface ContentSplitContextValue {
|
|
22
|
+
open: boolean
|
|
23
|
+
setOpen: (open: boolean) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ContentSplitContext = React.createContext<ContentSplitContextValue | null>(null)
|
|
27
|
+
|
|
28
|
+
export interface ContentSplitProps {
|
|
29
|
+
/** Main column (the document). */
|
|
30
|
+
children: React.ReactNode
|
|
31
|
+
/** Companion panel content (the checklist / help / activity). */
|
|
32
|
+
aside: React.ReactNode
|
|
33
|
+
/** Panel title — Sheet header on mobile, card header on desktop. */
|
|
34
|
+
asideTitle: React.ReactNode
|
|
35
|
+
/** Optional subtitle under the title. */
|
|
36
|
+
asideDescription?: React.ReactNode
|
|
37
|
+
/** Desktop column width (defaults to 20rem). */
|
|
38
|
+
asideWidth?: string
|
|
39
|
+
/** Controlled mobile-sheet state; uncontrolled when omitted. */
|
|
40
|
+
asideOpen?: boolean
|
|
41
|
+
onAsideOpenChange?: (open: boolean) => void
|
|
42
|
+
/** Desktop column can collapse into a slim reopen rail. */
|
|
43
|
+
collapsible?: boolean
|
|
44
|
+
/** Initial desktop collapsed state (e.g. expand only on the relevant day). */
|
|
45
|
+
defaultCollapsed?: boolean
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ContentSplit({
|
|
50
|
+
children,
|
|
51
|
+
aside,
|
|
52
|
+
asideTitle,
|
|
53
|
+
asideDescription,
|
|
54
|
+
asideWidth = '20rem',
|
|
55
|
+
asideOpen,
|
|
56
|
+
onAsideOpenChange,
|
|
57
|
+
collapsible = false,
|
|
58
|
+
defaultCollapsed = false,
|
|
59
|
+
className,
|
|
60
|
+
}: ContentSplitProps) {
|
|
61
|
+
const [internalOpen, setInternalOpen] = React.useState(false)
|
|
62
|
+
const open = asideOpen ?? internalOpen
|
|
63
|
+
const setOpen = onAsideOpenChange ?? setInternalOpen
|
|
64
|
+
const [collapsed, setCollapsed] = React.useState(collapsible && defaultCollapsed)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ContentSplitContext.Provider value={{ open, setOpen }}>
|
|
68
|
+
<div
|
|
69
|
+
className={cn('lg:grid lg:items-start lg:gap-6', className)}
|
|
70
|
+
style={{ gridTemplateColumns: `minmax(0, 1fr) ${collapsed ? 'auto' : asideWidth}` }}
|
|
71
|
+
>
|
|
72
|
+
<div className="min-w-0">{children}</div>
|
|
73
|
+
|
|
74
|
+
{/* Desktop: docked sticky column (or slim reopen rail when collapsed) */}
|
|
75
|
+
<aside className="hidden lg:block lg:sticky lg:top-4">
|
|
76
|
+
{collapsed ? (
|
|
77
|
+
<Button
|
|
78
|
+
variant="outline"
|
|
79
|
+
size="sm"
|
|
80
|
+
onClick={() => setCollapsed(false)}
|
|
81
|
+
className="h-9 w-9 p-0"
|
|
82
|
+
aria-label={typeof asideTitle === 'string' ? asideTitle : undefined}
|
|
83
|
+
title={typeof asideTitle === 'string' ? asideTitle : undefined}
|
|
84
|
+
>
|
|
85
|
+
<PanelRightOpen className="h-4 w-4" />
|
|
86
|
+
</Button>
|
|
87
|
+
) : (
|
|
88
|
+
<div className="rounded-card border border-border bg-card">
|
|
89
|
+
<div className="flex items-start justify-between gap-2 border-b border-border px-4 py-3">
|
|
90
|
+
<div className="min-w-0">
|
|
91
|
+
<p className="text-sm font-semibold text-foreground">{asideTitle}</p>
|
|
92
|
+
{asideDescription && (
|
|
93
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{asideDescription}</p>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
{collapsible && (
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => setCollapsed(true)}
|
|
100
|
+
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
101
|
+
aria-label="collapse"
|
|
102
|
+
>
|
|
103
|
+
<PanelRightClose className="h-4 w-4" />
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
<div className="p-4">{aside}</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</aside>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Mobile: right sheet */}
|
|
114
|
+
<div className="lg:hidden">
|
|
115
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
116
|
+
<SheetContent width="max-w-sm">
|
|
117
|
+
<SheetHeader>
|
|
118
|
+
<SheetTitle>{asideTitle}</SheetTitle>
|
|
119
|
+
{asideDescription && <SheetDescription>{asideDescription}</SheetDescription>}
|
|
120
|
+
</SheetHeader>
|
|
121
|
+
<SheetBody>{aside}</SheetBody>
|
|
122
|
+
</SheetContent>
|
|
123
|
+
</Sheet>
|
|
124
|
+
</div>
|
|
125
|
+
</ContentSplitContext.Provider>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ContentSplitTriggerProps {
|
|
130
|
+
/** Accessible label; also the visible text unless `iconOnly`. */
|
|
131
|
+
label: React.ReactNode
|
|
132
|
+
iconOnly?: boolean
|
|
133
|
+
className?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Opens the mobile aside sheet. Renders nothing from `lg` up (the panel is
|
|
137
|
+
* already docked). Place inside SubpageHeader `actions` or any toolbar. */
|
|
138
|
+
export function ContentSplitTrigger({ label, iconOnly, className }: ContentSplitTriggerProps) {
|
|
139
|
+
const ctx = React.useContext(ContentSplitContext)
|
|
140
|
+
if (!ctx) return null
|
|
141
|
+
return (
|
|
142
|
+
<Button
|
|
143
|
+
variant="outline"
|
|
144
|
+
size="sm"
|
|
145
|
+
onClick={() => ctx.setOpen(true)}
|
|
146
|
+
className={cn('lg:hidden', className)}
|
|
147
|
+
aria-label={typeof label === 'string' ? label : undefined}
|
|
148
|
+
>
|
|
149
|
+
<PanelRight className={cn('h-4 w-4', !iconOnly && 'mr-1.5')} />
|
|
150
|
+
{!iconOnly && label}
|
|
151
|
+
</Button>
|
|
152
|
+
)
|
|
153
|
+
}
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -218,24 +218,45 @@ function NavItem({
|
|
|
218
218
|
return content
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/** How specifically `route` matches `path` (-1 = no match; higher = more specific).
|
|
222
|
+
* An exact match beats a prefix match; a longer prefix beats a shorter one. */
|
|
223
|
+
function routeMatchScore(route: string, path: string): number {
|
|
224
|
+
if (path === route) return route.length + 1
|
|
225
|
+
if (route !== '/' && path.startsWith(route + '/')) return route.length
|
|
226
|
+
return -1
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The single most-specific nav route that matches the current path (longest wins).
|
|
230
|
+
* Prevents a parent like `/courses` from staying active on `/courses/sales`. */
|
|
231
|
+
function resolveActiveRoute(navigation: NavigationItem[], path: string): string | null {
|
|
232
|
+
let best: string | null = null
|
|
233
|
+
let bestScore = 0
|
|
234
|
+
const consider = (route?: string) => {
|
|
235
|
+
if (!route) return
|
|
236
|
+
const score = routeMatchScore(route, path)
|
|
237
|
+
if (score > bestScore) { bestScore = score; best = route }
|
|
238
|
+
}
|
|
239
|
+
for (const item of navigation) {
|
|
240
|
+
consider(item.route)
|
|
241
|
+
for (const child of item.children ?? []) consider(child.route)
|
|
242
|
+
}
|
|
243
|
+
return best
|
|
244
|
+
}
|
|
245
|
+
|
|
221
246
|
function NavGroup({
|
|
222
247
|
item,
|
|
223
248
|
collapsed,
|
|
224
|
-
|
|
249
|
+
activeRoute,
|
|
225
250
|
onNavigate,
|
|
226
251
|
}: {
|
|
227
252
|
item: NavigationItem
|
|
228
253
|
collapsed: boolean
|
|
229
|
-
|
|
254
|
+
activeRoute: string | null
|
|
230
255
|
onNavigate: (route: string) => void
|
|
231
256
|
}) {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
)
|
|
257
|
+
const isChildActive = item.children?.some((c) => c.route === activeRoute)
|
|
258
|
+
const [open, setOpen] = React.useState(() => isChildActive ?? false)
|
|
235
259
|
const Icon = getIcon(item.icon)
|
|
236
|
-
const isChildActive = item.children?.some(
|
|
237
|
-
(c) => currentPath === c.route || currentPath.startsWith(c.route + '/')
|
|
238
|
-
)
|
|
239
260
|
|
|
240
261
|
if (collapsed) {
|
|
241
262
|
return (
|
|
@@ -280,7 +301,7 @@ function NavGroup({
|
|
|
280
301
|
<div className="ml-4 mt-0.5 space-y-0.5 border-l border-sidebar-border/50 pl-3">
|
|
281
302
|
{item.children?.map((child) => {
|
|
282
303
|
const ChildIcon = getIcon(child.icon)
|
|
283
|
-
const isActive =
|
|
304
|
+
const isActive = child.route === activeRoute
|
|
284
305
|
return (
|
|
285
306
|
<button
|
|
286
307
|
key={child.id}
|
|
@@ -322,6 +343,10 @@ export function Sidebar({
|
|
|
322
343
|
const currentPath = currentPathProp
|
|
323
344
|
const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
|
|
324
345
|
|
|
346
|
+
// Most-specific match wins, so sibling routes that share a prefix (e.g.
|
|
347
|
+
// /courses vs /courses/sales) don't both light up.
|
|
348
|
+
const activeRoute = resolveActiveRoute(navigation, currentPath)
|
|
349
|
+
|
|
325
350
|
const mainItems = navigation.filter((item) => item.section === 'main')
|
|
326
351
|
const secondaryItems = navigation.filter((item) => item.section === 'secondary')
|
|
327
352
|
const settingsItems = navigation.filter((item) => item.section === 'settings')
|
|
@@ -379,13 +404,13 @@ export function Sidebar({
|
|
|
379
404
|
<div className="space-y-1">
|
|
380
405
|
{mainItems.map((item) =>
|
|
381
406
|
item.children && item.children.length > 0 ? (
|
|
382
|
-
<NavGroup key={item.id} item={item} collapsed={collapsed}
|
|
407
|
+
<NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
|
|
383
408
|
) : (
|
|
384
409
|
<NavItem
|
|
385
410
|
key={item.id}
|
|
386
411
|
item={item}
|
|
387
412
|
collapsed={collapsed}
|
|
388
|
-
isActive={
|
|
413
|
+
isActive={item.route === activeRoute}
|
|
389
414
|
onNavigate={handleNavigate}
|
|
390
415
|
/>
|
|
391
416
|
)
|
|
@@ -397,13 +422,13 @@ export function Sidebar({
|
|
|
397
422
|
<div className="mt-6 space-y-1">
|
|
398
423
|
{secondaryItems.map((item) =>
|
|
399
424
|
item.children && item.children.length > 0 ? (
|
|
400
|
-
<NavGroup key={item.id} item={item} collapsed={collapsed}
|
|
425
|
+
<NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
|
|
401
426
|
) : (
|
|
402
427
|
<NavItem
|
|
403
428
|
key={item.id}
|
|
404
429
|
item={item}
|
|
405
430
|
collapsed={collapsed}
|
|
406
|
-
isActive={
|
|
431
|
+
isActive={item.route === activeRoute}
|
|
407
432
|
onNavigate={handleNavigate}
|
|
408
433
|
/>
|
|
409
434
|
)
|
|
@@ -420,7 +445,7 @@ export function Sidebar({
|
|
|
420
445
|
key={item.id}
|
|
421
446
|
item={item}
|
|
422
447
|
collapsed={collapsed}
|
|
423
|
-
isActive={
|
|
448
|
+
isActive={item.route === activeRoute}
|
|
424
449
|
onNavigate={handleNavigate}
|
|
425
450
|
/>
|
|
426
451
|
))}
|
package/src/layout/Topbar.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
Package,
|
|
16
16
|
Activity,
|
|
17
17
|
BarChart3,
|
|
18
|
+
Clapperboard,
|
|
18
19
|
FileText,
|
|
19
20
|
Mail,
|
|
20
21
|
DollarSign,
|
|
@@ -206,7 +207,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
|
|
206
207
|
Contact, Building2, ChevronDown, Filter, Plus, Shield, List, ListChecks, FolderOpen, Dog, Cat, PawPrint, Heart, LayoutTemplate, LeafyGreen, Apple, Egg, Wheat,
|
|
207
208
|
ArrowDownCircle, ArrowUpCircle, ArrowLeftRight, Plug, SlidersHorizontal, TableProperties, Landmark, Receipt, TrendingUp, CircleDollarSign, Clock, AlertTriangle, Sparkles, Wallet, Warehouse, Ruler, ArrowUpRight, ArrowDownRight, UserPlus, UserX, TreePalm,
|
|
208
209
|
Boxes, Box, FileCheck2, PartyPopper, Radio, CalendarDays, BadgeDollarSign, Banknote, Layers, Music, Eye, ListMusic, Disc3, UsersRound, Mic,
|
|
209
|
-
CalendarCheck2, CalendarX, ListPlus, Map, Ban, Clock3, Inbox, UserCheck, Star, Zap,
|
|
210
|
+
CalendarCheck2, CalendarX, ListPlus, Map, Ban, Clock3, Inbox, UserCheck, Star, Zap, Clapperboard,
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
function getIcon(name: string): LucideIcon {
|
package/src/layout/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ const DropdownSubTrigger = React.forwardRef<
|
|
|
25
25
|
<DropdownMenuPrimitive.SubTrigger
|
|
26
26
|
ref={ref}
|
|
27
27
|
className={cn(
|
|
28
|
-
'flex cursor-default select-none items-center rounded-
|
|
28
|
+
'flex cursor-default select-none items-center rounded-button px-2 py-1.5 text-sm outline-none focus:bg-muted data-[state=open]:bg-muted',
|
|
29
29
|
inset && 'pl-8',
|
|
30
30
|
className
|
|
31
31
|
)}
|
|
@@ -79,7 +79,7 @@ const DropdownItem = React.forwardRef<
|
|
|
79
79
|
<DropdownMenuPrimitive.Item
|
|
80
80
|
ref={ref}
|
|
81
81
|
className={cn(
|
|
82
|
-
'relative flex cursor-default select-none items-center rounded-
|
|
82
|
+
'relative flex cursor-default select-none items-center rounded-button px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
83
83
|
inset && 'pl-8',
|
|
84
84
|
className
|
|
85
85
|
)}
|
|
@@ -95,7 +95,7 @@ const DropdownCheckboxItem = React.forwardRef<
|
|
|
95
95
|
<DropdownMenuPrimitive.CheckboxItem
|
|
96
96
|
ref={ref}
|
|
97
97
|
className={cn(
|
|
98
|
-
'relative flex cursor-default select-none items-center rounded-
|
|
98
|
+
'relative flex cursor-default select-none items-center rounded-button py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
99
99
|
className
|
|
100
100
|
)}
|
|
101
101
|
checked={checked}
|
|
@@ -119,7 +119,7 @@ const DropdownRadioItem = React.forwardRef<
|
|
|
119
119
|
<DropdownMenuPrimitive.RadioItem
|
|
120
120
|
ref={ref}
|
|
121
121
|
className={cn(
|
|
122
|
-
'relative flex cursor-default select-none items-center rounded-
|
|
122
|
+
'relative flex cursor-default select-none items-center rounded-button py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
123
123
|
className
|
|
124
124
|
)}
|
|
125
125
|
{...props}
|
package/src/primitives/index.ts
CHANGED
|
@@ -81,3 +81,5 @@ export { CurrencyInput } from './currency-input'
|
|
|
81
81
|
export { ConfirmDialog } from './confirm-dialog'
|
|
82
82
|
export { Popover, PopoverTrigger, PopoverContent } from './popover'
|
|
83
83
|
export { SegmentedControl, type SegmentedControlProps, type SegmentedOption } from './segmented-control'
|
|
84
|
+
export { Markdown, renderMarkdown, type MarkdownProps } from './markdown'
|
|
85
|
+
export { MarkdownEditor, type MarkdownEditorProps } from './markdown-editor'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../utils/cn'
|
|
3
|
+
import { SegmentedControl } from './segmented-control'
|
|
4
|
+
import { Markdown } from './markdown'
|
|
5
|
+
|
|
6
|
+
export interface MarkdownEditorProps {
|
|
7
|
+
value: string
|
|
8
|
+
onChange: (value: string) => void
|
|
9
|
+
placeholder?: string
|
|
10
|
+
/** Minimum visible rows for the textarea. */
|
|
11
|
+
minRows?: number
|
|
12
|
+
defaultMode?: 'edit' | 'preview'
|
|
13
|
+
/** i18n injection — ui stays locale-agnostic. */
|
|
14
|
+
editLabel?: string
|
|
15
|
+
previewLabel?: string
|
|
16
|
+
/** Custom preview renderer — e.g. a social-post mockup instead of plain
|
|
17
|
+
* markdown. Defaults to the built-in Markdown renderer. */
|
|
18
|
+
renderPreview?: (value: string) => React.ReactNode
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Markdown editor with an edit/preview toggle. Plain textarea + the zero-dep
|
|
24
|
+
* Markdown renderer — deliberately not a block/WYSIWYG editor (that's a later
|
|
25
|
+
* milestone); this covers script/brief documents today.
|
|
26
|
+
*/
|
|
27
|
+
export function MarkdownEditor({
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
placeholder,
|
|
31
|
+
minRows = 16,
|
|
32
|
+
defaultMode = 'preview',
|
|
33
|
+
editLabel = 'Edit',
|
|
34
|
+
previewLabel = 'Preview',
|
|
35
|
+
renderPreview,
|
|
36
|
+
className,
|
|
37
|
+
}: MarkdownEditorProps) {
|
|
38
|
+
const [mode, setMode] = React.useState<'edit' | 'preview'>(
|
|
39
|
+
defaultMode === 'preview' && value.trim() === '' ? 'edit' : defaultMode,
|
|
40
|
+
)
|
|
41
|
+
return (
|
|
42
|
+
<div className={cn('flex flex-col gap-2', className)}>
|
|
43
|
+
<div className="flex justify-end">
|
|
44
|
+
<SegmentedControl
|
|
45
|
+
aria-label="Markdown mode"
|
|
46
|
+
options={[
|
|
47
|
+
{ value: 'edit', label: editLabel },
|
|
48
|
+
{ value: 'preview', label: previewLabel },
|
|
49
|
+
]}
|
|
50
|
+
value={mode}
|
|
51
|
+
onChange={(m) => setMode(m as 'edit' | 'preview')}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
{mode === 'edit' ? (
|
|
55
|
+
<textarea
|
|
56
|
+
value={value}
|
|
57
|
+
onChange={(e) => onChange(e.target.value)}
|
|
58
|
+
placeholder={placeholder}
|
|
59
|
+
rows={Math.max(minRows, value.split('\n').length + 1)}
|
|
60
|
+
className={cn(
|
|
61
|
+
'w-full resize-y rounded-md border border-border bg-card p-3',
|
|
62
|
+
'font-mono text-[13px] leading-relaxed text-foreground',
|
|
63
|
+
'placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring',
|
|
64
|
+
)}
|
|
65
|
+
/>
|
|
66
|
+
) : renderPreview ? (
|
|
67
|
+
renderPreview(value)
|
|
68
|
+
) : (
|
|
69
|
+
<div className="rounded-md border border-border bg-card p-4">
|
|
70
|
+
<Markdown source={value} />
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../utils/cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal markdown renderer — builds React elements directly (no HTML string,
|
|
6
|
+
* no dangerouslySetInnerHTML), so untrusted content is safe by construction.
|
|
7
|
+
* Supports the subset content docs actually use: h1–h3, ---, blockquote,
|
|
8
|
+
* ul/ol, paragraphs, and inline **bold** / *italic* / `code`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const INLINE_TOKEN = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g
|
|
12
|
+
|
|
13
|
+
function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
|
|
14
|
+
return text.split(INLINE_TOKEN).map((part, i) => {
|
|
15
|
+
const key = `${keyPrefix}-${i}`
|
|
16
|
+
if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
|
|
17
|
+
return <strong key={key}>{part.slice(2, -2)}</strong>
|
|
18
|
+
}
|
|
19
|
+
if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
|
|
20
|
+
return <em key={key}>{part.slice(1, -1)}</em>
|
|
21
|
+
}
|
|
22
|
+
if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
|
|
23
|
+
return (
|
|
24
|
+
<code key={key} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
|
|
25
|
+
{part.slice(1, -1)}
|
|
26
|
+
</code>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
return part === '' ? null : <React.Fragment key={key}>{part}</React.Fragment>
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Block =
|
|
34
|
+
| { type: 'heading'; level: 1 | 2 | 3; text: string }
|
|
35
|
+
| { type: 'hr' }
|
|
36
|
+
| { type: 'blockquote'; lines: string[] }
|
|
37
|
+
| { type: 'ul'; items: string[] }
|
|
38
|
+
| { type: 'ol'; items: string[] }
|
|
39
|
+
| { type: 'p'; lines: string[] }
|
|
40
|
+
|
|
41
|
+
function parseBlocks(source: string): Block[] {
|
|
42
|
+
const lines = source.replace(/\r\n/g, '\n').split('\n')
|
|
43
|
+
const blocks: Block[] = []
|
|
44
|
+
let i = 0
|
|
45
|
+
while (i < lines.length) {
|
|
46
|
+
const line = lines[i]
|
|
47
|
+
const trimmed = line.trim()
|
|
48
|
+
if (trimmed === '') { i++; continue }
|
|
49
|
+
const heading = /^(#{1,3})\s+(.*)$/.exec(trimmed)
|
|
50
|
+
if (heading) {
|
|
51
|
+
blocks.push({ type: 'heading', level: heading[1].length as 1 | 2 | 3, text: heading[2] })
|
|
52
|
+
i++
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (/^(-{3,}|\*{3,})$/.test(trimmed)) {
|
|
56
|
+
blocks.push({ type: 'hr' })
|
|
57
|
+
i++
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (trimmed.startsWith('>')) {
|
|
61
|
+
const quote: string[] = []
|
|
62
|
+
while (i < lines.length && lines[i].trim().startsWith('>')) {
|
|
63
|
+
quote.push(lines[i].trim().replace(/^>\s?/, ''))
|
|
64
|
+
i++
|
|
65
|
+
}
|
|
66
|
+
blocks.push({ type: 'blockquote', lines: quote })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (/^[-*]\s+/.test(trimmed)) {
|
|
70
|
+
const items: string[] = []
|
|
71
|
+
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
|
72
|
+
items.push(lines[i].trim().replace(/^[-*]\s+/, ''))
|
|
73
|
+
i++
|
|
74
|
+
}
|
|
75
|
+
blocks.push({ type: 'ul', items })
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (/^\d+[.)]\s+/.test(trimmed)) {
|
|
79
|
+
const items: string[] = []
|
|
80
|
+
while (i < lines.length && /^\d+[.)]\s+/.test(lines[i].trim())) {
|
|
81
|
+
items.push(lines[i].trim().replace(/^\d+[.)]\s+/, ''))
|
|
82
|
+
i++
|
|
83
|
+
}
|
|
84
|
+
blocks.push({ type: 'ol', items })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const para: string[] = []
|
|
88
|
+
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,3}\s|>|[-*]\s|\d+[.)]\s|-{3,}$|\*{3,}$)/.test(lines[i].trim())) {
|
|
89
|
+
para.push(lines[i].trim())
|
|
90
|
+
i++
|
|
91
|
+
}
|
|
92
|
+
blocks.push({ type: 'p', lines: para })
|
|
93
|
+
}
|
|
94
|
+
return blocks
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const HEADING_CLASS: Record<1 | 2 | 3, string> = {
|
|
98
|
+
1: 'text-xl font-semibold tracking-tight',
|
|
99
|
+
2: 'text-base font-semibold tracking-tight',
|
|
100
|
+
3: 'text-sm font-semibold',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function renderMarkdown(source: string): React.ReactNode {
|
|
104
|
+
const blocks = parseBlocks(source)
|
|
105
|
+
return blocks.map((block, bi) => {
|
|
106
|
+
const key = `md-${bi}`
|
|
107
|
+
switch (block.type) {
|
|
108
|
+
case 'heading': {
|
|
109
|
+
const Tag = (`h${block.level}`) as 'h1' | 'h2' | 'h3'
|
|
110
|
+
return (
|
|
111
|
+
<Tag key={key} className={HEADING_CLASS[block.level]}>
|
|
112
|
+
{renderInline(block.text, key)}
|
|
113
|
+
</Tag>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
case 'hr':
|
|
117
|
+
return <hr key={key} className="border-border" />
|
|
118
|
+
case 'blockquote':
|
|
119
|
+
return (
|
|
120
|
+
<blockquote key={key} className="border-l-2 border-border pl-3 text-muted-foreground">
|
|
121
|
+
{block.lines.map((l, li) => (
|
|
122
|
+
<p key={`${key}-${li}`}>{renderInline(l, `${key}-${li}`)}</p>
|
|
123
|
+
))}
|
|
124
|
+
</blockquote>
|
|
125
|
+
)
|
|
126
|
+
case 'ul':
|
|
127
|
+
return (
|
|
128
|
+
<ul key={key} className="list-disc space-y-1 pl-5">
|
|
129
|
+
{block.items.map((item, li) => (
|
|
130
|
+
<li key={`${key}-${li}`}>{renderInline(item, `${key}-${li}`)}</li>
|
|
131
|
+
))}
|
|
132
|
+
</ul>
|
|
133
|
+
)
|
|
134
|
+
case 'ol':
|
|
135
|
+
return (
|
|
136
|
+
<ol key={key} className="list-decimal space-y-1 pl-5">
|
|
137
|
+
{block.items.map((item, li) => (
|
|
138
|
+
<li key={`${key}-${li}`}>{renderInline(item, `${key}-${li}`)}</li>
|
|
139
|
+
))}
|
|
140
|
+
</ol>
|
|
141
|
+
)
|
|
142
|
+
case 'p':
|
|
143
|
+
return (
|
|
144
|
+
<p key={key}>
|
|
145
|
+
{block.lines.map((l, li) => (
|
|
146
|
+
<React.Fragment key={`${key}-${li}`}>
|
|
147
|
+
{li > 0 && <br />}
|
|
148
|
+
{renderInline(l, `${key}-${li}`)}
|
|
149
|
+
</React.Fragment>
|
|
150
|
+
))}
|
|
151
|
+
</p>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface MarkdownProps {
|
|
158
|
+
source: string
|
|
159
|
+
className?: string
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Rendered markdown block — spacing tuned for script/brief documents. */
|
|
163
|
+
export function Markdown({ source, className }: MarkdownProps) {
|
|
164
|
+
return (
|
|
165
|
+
<div className={cn('space-y-3 text-sm leading-relaxed text-foreground', className)}>
|
|
166
|
+
{renderMarkdown(source)}
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
|
|
|
116
116
|
<SelectPrimitive.Item
|
|
117
117
|
ref={ref}
|
|
118
118
|
className={cn(
|
|
119
|
-
'relative flex w-full cursor-default select-none items-center rounded-
|
|
119
|
+
'relative flex w-full cursor-default select-none items-center rounded-button py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
120
120
|
className
|
|
121
121
|
)}
|
|
122
122
|
{...props}
|