@orsetra/shared-ui 1.10.2 → 1.10.4
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/components/layout/layout-container.tsx +1 -0
- package/components/layout/sidebar/data.tsx +2 -0
- package/components/layout/sidebar/main-sidebar-flyout.tsx +41 -2
- package/components/layout/sidebar/main-sidebar.tsx +14 -4
- package/components/layout/sidebar/quick-access-tracker.tsx +3 -2
- package/components/layout/sidebar/sidebar.tsx +48 -13
- package/hooks/use-quick-access.ts +64 -20
- package/package.json +1 -1
|
@@ -54,6 +54,7 @@ export interface SubMenuItem {
|
|
|
54
54
|
href: string
|
|
55
55
|
icon: LucideIcon
|
|
56
56
|
applied?: boolean
|
|
57
|
+
target?: string
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export interface SidebarMenus {
|
|
@@ -86,6 +87,7 @@ export interface ApiSubMenuItem {
|
|
|
86
87
|
description?: string
|
|
87
88
|
href: string
|
|
88
89
|
icon: string
|
|
90
|
+
target?: string
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
export interface ApiSubMenu {
|
|
@@ -79,6 +79,8 @@ export function MainSidebarFlyout({
|
|
|
79
79
|
<a
|
|
80
80
|
key={sub.id}
|
|
81
81
|
href={sub.href}
|
|
82
|
+
target={sub.target}
|
|
83
|
+
rel={sub.target === "_blank" ? "noopener noreferrer" : undefined}
|
|
82
84
|
className={cn(
|
|
83
85
|
"flex items-start transition-colors border-l-4",
|
|
84
86
|
"px-2 lg:px-3 py-2 gap-x-3",
|
|
@@ -99,10 +101,10 @@ export function MainSidebarFlyout({
|
|
|
99
101
|
</>
|
|
100
102
|
) : hasQuickAccess ? (
|
|
101
103
|
/* ── Quick access: pages recently visited ────────────────────────── */
|
|
102
|
-
<div className="flex flex-col h-full
|
|
104
|
+
<div className="flex flex-col h-full px-6 py-5 gap-5 overflow-y-auto">
|
|
103
105
|
<div className="w-full">
|
|
104
106
|
<p className="px-2 pb-1 text-[10px] font-semibold text-text-secondary uppercase tracking-widest">
|
|
105
|
-
|
|
107
|
+
Quick Access
|
|
106
108
|
</p>
|
|
107
109
|
<nav className="space-y-0.5">
|
|
108
110
|
{quickAccessItems.map((qa) => {
|
|
@@ -126,6 +128,43 @@ export function MainSidebarFlyout({
|
|
|
126
128
|
})}
|
|
127
129
|
</nav>
|
|
128
130
|
</div>
|
|
131
|
+
|
|
132
|
+
{defaultLinks.length > 0 && (
|
|
133
|
+
<div className="w-full border-t border-ui-border pt-4">
|
|
134
|
+
<p className="px-2 pb-1 text-[10px] font-semibold text-text-secondary uppercase tracking-widest">
|
|
135
|
+
Resources
|
|
136
|
+
</p>
|
|
137
|
+
<nav className="space-y-0.5">
|
|
138
|
+
{defaultLinks.map((link, i) => {
|
|
139
|
+
const isExternal = link.href.startsWith("http://") || link.href.startsWith("https://")
|
|
140
|
+
const LinkIcon: LucideIcon = !link.icon
|
|
141
|
+
? ExternalLink
|
|
142
|
+
: typeof link.icon === "string"
|
|
143
|
+
? resolveIcon(link.icon)
|
|
144
|
+
: link.icon
|
|
145
|
+
return (
|
|
146
|
+
<a
|
|
147
|
+
key={i}
|
|
148
|
+
href={link.href}
|
|
149
|
+
{...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
150
|
+
className="flex items-start gap-x-3 px-2 py-2 border-l-4 border-transparent hover:bg-ui-background hover:border-interactive transition-colors"
|
|
151
|
+
>
|
|
152
|
+
<LinkIcon className="h-4 w-4 flex-shrink-0 mt-0.5 text-text-secondary" />
|
|
153
|
+
<div className="min-w-0 flex-1">
|
|
154
|
+
<p className="text-sm font-medium text-text-primary leading-snug flex items-center gap-1.5">
|
|
155
|
+
{link.label}
|
|
156
|
+
{isExternal && <ExternalLink className="h-3 w-3 text-text-secondary flex-shrink-0" />}
|
|
157
|
+
</p>
|
|
158
|
+
{link.description && (
|
|
159
|
+
<p className="text-xs text-text-secondary leading-relaxed mt-0.5 line-clamp-2">{link.description}</p>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
</a>
|
|
163
|
+
)
|
|
164
|
+
})}
|
|
165
|
+
</nav>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
129
168
|
</div>
|
|
130
169
|
) : (
|
|
131
170
|
/* ── Default state: centré verticalement ────────────────────────── */
|
|
@@ -142,7 +142,7 @@ export function MainSidebar({
|
|
|
142
142
|
try {
|
|
143
143
|
const resolved = applyTemplateVars(href)
|
|
144
144
|
if (resolved.startsWith('http://') || resolved.startsWith('https://')) {
|
|
145
|
-
return
|
|
145
|
+
return resolved // keep full URL for external links
|
|
146
146
|
}
|
|
147
147
|
const cleanHref = resolved.replace(/^\//, '')
|
|
148
148
|
const cleanBase = (main_base_url.startsWith('http://') || main_base_url.startsWith('https://'))
|
|
@@ -165,9 +165,17 @@ export function MainSidebar({
|
|
|
165
165
|
icon: l.icon,
|
|
166
166
|
}))
|
|
167
167
|
|
|
168
|
-
const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string) => {
|
|
168
|
+
const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string, target?: string) => {
|
|
169
169
|
e.preventDefault()
|
|
170
|
-
|
|
170
|
+
const resolved = buildSubItemHref(menuId, href)
|
|
171
|
+
if (target === "_blank") {
|
|
172
|
+
window.open(resolved, "_blank", "noopener,noreferrer")
|
|
173
|
+
} else {
|
|
174
|
+
const path = resolved.startsWith('http://') || resolved.startsWith('https://')
|
|
175
|
+
? safeUrlPathname(resolved)
|
|
176
|
+
: resolved
|
|
177
|
+
window.location.href = path
|
|
178
|
+
}
|
|
171
179
|
setExpandedMenu(null)
|
|
172
180
|
}
|
|
173
181
|
|
|
@@ -284,7 +292,9 @@ export function MainSidebar({
|
|
|
284
292
|
<a
|
|
285
293
|
key={subItem.id}
|
|
286
294
|
href={buildSubItemHref(item.id, subItem.href)}
|
|
287
|
-
|
|
295
|
+
target={subItem.target}
|
|
296
|
+
rel={subItem.target === "_blank" ? "noopener noreferrer" : undefined}
|
|
297
|
+
onClick={(e) => handleSubMenuClick(e, item.id, subItem.href, subItem.target)}
|
|
288
298
|
className="flex items-center gap-2 lg:gap-3 px-2 lg:px-3 py-1.5 lg:py-2 text-sm text-text-secondary hover:bg-ui-background hover:text-text-primary transition-colors no-underline border-l-2 border-ui-border hover:border-interactive"
|
|
289
299
|
>
|
|
290
300
|
<subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
|
|
@@ -27,8 +27,9 @@ export function QuickAccessTracker({ label, icon, enabled = true }: QuickAccessT
|
|
|
27
27
|
|
|
28
28
|
useEffect(() => {
|
|
29
29
|
if (!enabled || !label || !pathname) return
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Use clean URL (no query params) — useQuickAccess normalises further
|
|
31
|
+
const href = window.location.origin + window.location.pathname
|
|
32
|
+
addRef.current({ label, href, icon })
|
|
32
33
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
34
|
}, [pathname, label, icon, enabled])
|
|
34
35
|
|
|
@@ -325,25 +325,40 @@ function Sidebar({ currentMenu, sidebarMenus = {}, main_base_url = "", sectionLa
|
|
|
325
325
|
|
|
326
326
|
const handleClick = (e: React.MouseEvent) => {
|
|
327
327
|
const href = item.href
|
|
328
|
+
if (item.target === "_blank") {
|
|
329
|
+
e.preventDefault()
|
|
330
|
+
try {
|
|
331
|
+
let resolved = href
|
|
332
|
+
if (typeof window !== "undefined") {
|
|
333
|
+
const projRaw = localStorage.getItem("current-business-unit")
|
|
334
|
+
const cp = typeof projRaw === "string" ? (JSON.parse(projRaw)?.state?.currentProject ?? "") : ""
|
|
335
|
+
if (cp) resolved = resolved.replace(/\$\{currentProject\}/g, cp)
|
|
336
|
+
const envRaw = localStorage.getItem("current-environment")
|
|
337
|
+
const ce = typeof envRaw === "string" ? (JSON.parse(envRaw)?.state?.currentEnv ?? "") : ""
|
|
338
|
+
if (ce) resolved = resolved.replace(/\$\{currentEnv\}/g, ce)
|
|
339
|
+
}
|
|
340
|
+
window.open(resolved, "_blank", "noopener,noreferrer")
|
|
341
|
+
} catch {
|
|
342
|
+
window.open(href, "_blank", "noopener,noreferrer")
|
|
343
|
+
}
|
|
344
|
+
return
|
|
345
|
+
}
|
|
328
346
|
if (href.startsWith('http://') || href.startsWith('https://')) {
|
|
329
347
|
e.preventDefault()
|
|
330
348
|
window.location.href = new URL(href).pathname
|
|
331
349
|
}
|
|
332
350
|
}
|
|
333
351
|
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
: "text-text-secondary hover:bg-ui-background hover:text-text-primary border-transparent"
|
|
345
|
-
)}
|
|
346
|
-
>
|
|
352
|
+
const linkCls = cn(
|
|
353
|
+
"flex items-center transition-colors border-l-4",
|
|
354
|
+
isCollapsed ? "justify-center p-2" : "px-2 lg:px-3 py-1.5 lg:py-2 gap-x-3 text-sm",
|
|
355
|
+
isActive
|
|
356
|
+
? "bg-interactive/10 text-interactive font-medium border-interactive"
|
|
357
|
+
: "text-text-secondary hover:bg-ui-background hover:text-text-primary border-transparent"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
const linkContent = (
|
|
361
|
+
<>
|
|
347
362
|
<item.icon
|
|
348
363
|
className="h-4 w-4 flex-shrink-0"
|
|
349
364
|
style={{ color: isActive ? '#0f62fe' : '#4589ff' }}
|
|
@@ -351,6 +366,26 @@ function Sidebar({ currentMenu, sidebarMenus = {}, main_base_url = "", sectionLa
|
|
|
351
366
|
{!isCollapsed && (
|
|
352
367
|
<span className="flex-1 truncate">{item.name}</span>
|
|
353
368
|
)}
|
|
369
|
+
</>
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
const linkEl = item.target === "_blank" ? (
|
|
373
|
+
<a
|
|
374
|
+
key={item.id}
|
|
375
|
+
href={item.href}
|
|
376
|
+
onClick={handleClick}
|
|
377
|
+
className={linkCls}
|
|
378
|
+
>
|
|
379
|
+
{linkContent}
|
|
380
|
+
</a>
|
|
381
|
+
) : (
|
|
382
|
+
<Link
|
|
383
|
+
key={item.id}
|
|
384
|
+
href={item.href}
|
|
385
|
+
onClick={handleClick}
|
|
386
|
+
className={linkCls}
|
|
387
|
+
>
|
|
388
|
+
{linkContent}
|
|
354
389
|
</Link>
|
|
355
390
|
)
|
|
356
391
|
|
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback } from "react"
|
|
4
4
|
|
|
5
|
-
const STORAGE_KEY
|
|
6
|
-
const
|
|
5
|
+
const STORAGE_KEY = "sidebar:quick-access"
|
|
6
|
+
const POOL_SIZE = 20 // internal storage
|
|
7
|
+
const DISPLAY_SIZE = 4 // shown in UI
|
|
8
|
+
const DECAY_DAYS = 14 // recency half-life
|
|
7
9
|
|
|
8
10
|
export interface QuickAccessItem {
|
|
9
|
-
id:
|
|
10
|
-
label:
|
|
11
|
-
href:
|
|
12
|
-
icon?:
|
|
11
|
+
id: string // origin + pathname (stable, no query params)
|
|
12
|
+
label: string
|
|
13
|
+
href: string // same as id — clean URL for navigation
|
|
14
|
+
icon?: string
|
|
15
|
+
visitCount: number
|
|
16
|
+
lastVisit: number // unix ms
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Score: balances visit frequency with recency. Items fade after DECAY_DAYS days. */
|
|
20
|
+
function score(item: QuickAccessItem): number {
|
|
21
|
+
const ageMs = Date.now() - item.lastVisit
|
|
22
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24)
|
|
23
|
+
const recency = Math.exp(-ageDays / DECAY_DAYS) // 1.0 → 0 over time
|
|
24
|
+
return item.visitCount * 0.6 + recency * 0.4
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
function readStorage(): QuickAccessItem[] {
|
|
@@ -21,7 +33,12 @@ function readStorage(): QuickAccessItem[] {
|
|
|
21
33
|
return Array.isArray(parsed)
|
|
22
34
|
? parsed.filter(
|
|
23
35
|
(i): i is QuickAccessItem =>
|
|
24
|
-
i &&
|
|
36
|
+
i &&
|
|
37
|
+
typeof i.id === "string" &&
|
|
38
|
+
typeof i.label === "string" &&
|
|
39
|
+
typeof i.href === "string" &&
|
|
40
|
+
typeof i.visitCount === "number" &&
|
|
41
|
+
typeof i.lastVisit === "number"
|
|
25
42
|
)
|
|
26
43
|
: []
|
|
27
44
|
} catch {
|
|
@@ -33,42 +50,69 @@ function writeStorage(items: QuickAccessItem[]): void {
|
|
|
33
50
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) } catch {}
|
|
34
51
|
}
|
|
35
52
|
|
|
53
|
+
/** Top DISPLAY_SIZE items from pool, sorted by score descending. */
|
|
54
|
+
function topItems(pool: QuickAccessItem[]): QuickAccessItem[] {
|
|
55
|
+
return [...pool].sort((a, b) => score(b) - score(a)).slice(0, DISPLAY_SIZE)
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
export function useQuickAccess() {
|
|
37
|
-
const [
|
|
59
|
+
const [pool, setPool] = useState<QuickAccessItem[]>(readStorage)
|
|
60
|
+
const [items, setItems] = useState<QuickAccessItem[]>(() => topItems(readStorage()))
|
|
38
61
|
|
|
39
62
|
// Sync across tabs
|
|
40
63
|
useEffect(() => {
|
|
41
64
|
const onStorage = (e: StorageEvent) => {
|
|
42
|
-
if (e.key === STORAGE_KEY)
|
|
65
|
+
if (e.key === STORAGE_KEY) {
|
|
66
|
+
const p = readStorage()
|
|
67
|
+
setPool(p)
|
|
68
|
+
setItems(topItems(p))
|
|
69
|
+
}
|
|
43
70
|
}
|
|
44
71
|
window.addEventListener("storage", onStorage)
|
|
45
72
|
return () => window.removeEventListener("storage", onStorage)
|
|
46
73
|
}, [])
|
|
47
74
|
|
|
48
|
-
const addItem = useCallback((item:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
const addItem = useCallback((item: { label: string; href: string; icon?: string }) => {
|
|
76
|
+
// Normalize: strip query params and fragments — use origin + pathname as stable id
|
|
77
|
+
let id: string
|
|
78
|
+
try {
|
|
79
|
+
const u = new URL(item.href)
|
|
80
|
+
id = u.origin + u.pathname
|
|
81
|
+
} catch {
|
|
82
|
+
id = item.href
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setPool(prev => {
|
|
86
|
+
const existing = prev.find(i => i.id === id)
|
|
87
|
+
const updated: QuickAccessItem = existing
|
|
88
|
+
? { ...existing, label: item.label, icon: item.icon, visitCount: existing.visitCount + 1, lastVisit: Date.now() }
|
|
89
|
+
: { id, label: item.label, href: id, icon: item.icon, visitCount: 1, lastVisit: Date.now() }
|
|
90
|
+
|
|
91
|
+
// Merge into pool, sort by score, keep top POOL_SIZE
|
|
92
|
+
const next = [updated, ...prev.filter(i => i.id !== id)]
|
|
93
|
+
.sort((a, b) => score(b) - score(a))
|
|
94
|
+
.slice(0, POOL_SIZE)
|
|
95
|
+
|
|
55
96
|
writeStorage(next)
|
|
97
|
+
setItems(topItems(next))
|
|
56
98
|
return next
|
|
57
99
|
})
|
|
58
100
|
}, [])
|
|
59
101
|
|
|
60
|
-
const removeItem = useCallback((
|
|
61
|
-
|
|
62
|
-
const next = prev.filter(i => i.
|
|
102
|
+
const removeItem = useCallback((id: string) => {
|
|
103
|
+
setPool(prev => {
|
|
104
|
+
const next = prev.filter(i => i.id !== id)
|
|
63
105
|
writeStorage(next)
|
|
106
|
+
setItems(topItems(next))
|
|
64
107
|
return next
|
|
65
108
|
})
|
|
66
109
|
}, [])
|
|
67
110
|
|
|
68
111
|
const clearItems = useCallback(() => {
|
|
112
|
+
setPool([])
|
|
69
113
|
setItems([])
|
|
70
114
|
try { localStorage.removeItem(STORAGE_KEY) } catch {}
|
|
71
115
|
}, [])
|
|
72
116
|
|
|
73
|
-
return { items, addItem, removeItem, clearItems }
|
|
117
|
+
return { items, pool, addItem, removeItem, clearItems }
|
|
74
118
|
}
|