@exxatdesignux/ui 0.0.5 → 0.0.7
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/bin/init.mjs +29 -0
- package/package.json +7 -2
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +7 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +485 -0
- package/template/Logo/Exxat_Prism.svg +39 -0
- package/template/Logo/Exxat_one.svg +36 -0
- package/template/README.md +58 -0
- package/template/app/(app)/compliance/page.tsx +10 -0
- package/template/app/(app)/dashboard/loading.tsx +18 -0
- package/template/app/(app)/dashboard/page.tsx +36 -0
- package/template/app/(app)/data-list/[id]/page.tsx +28 -0
- package/template/app/(app)/data-list/new/page.tsx +31 -0
- package/template/app/(app)/data-list/page.tsx +10 -0
- package/template/app/(app)/error.tsx +43 -0
- package/template/app/(app)/help/page.tsx +34 -0
- package/template/app/(app)/layout.tsx +54 -0
- package/template/app/(app)/loading.tsx +18 -0
- package/template/app/(app)/question-bank/page.tsx +10 -0
- package/template/app/(app)/rotations/page.tsx +15 -0
- package/template/app/(app)/settings/page.tsx +17 -0
- package/template/app/(app)/sites/all/page.tsx +13 -0
- package/template/app/(app)/team/page.tsx +10 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +1811 -0
- package/template/app/layout.tsx +95 -0
- package/template/app/page.tsx +9 -0
- package/template/components/.gitkeep +0 -0
- package/template/components/app-sidebar-dynamic.tsx +15 -0
- package/template/components/app-sidebar.tsx +901 -0
- package/template/components/ask-leo-composer.tsx +216 -0
- package/template/components/ask-leo-sidebar.tsx +509 -0
- package/template/components/chart-area-interactive.tsx +293 -0
- package/template/components/charts-overview.tsx +2321 -0
- package/template/components/command-menu-01.tsx +133 -0
- package/template/components/command-menu-02.tsx +386 -0
- package/template/components/command-menu.tsx +182 -0
- package/template/components/compliance-board-view.tsx +134 -0
- package/template/components/compliance-client.tsx +92 -0
- package/template/components/compliance-list-view.tsx +59 -0
- package/template/components/compliance-page-header.tsx +89 -0
- package/template/components/compliance-table.tsx +525 -0
- package/template/components/dashboard-onboarding-gallery.tsx +13 -0
- package/template/components/dashboard-onboarding.tsx +21 -0
- package/template/components/dashboard-promo-banner.tsx +67 -0
- package/template/components/dashboard-quota-progress-card.tsx +369 -0
- package/template/components/dashboard-report-charts.tsx +69 -0
- package/template/components/dashboard-section-heading.tsx +68 -0
- package/template/components/dashboard-tabs.tsx +598 -0
- package/template/components/data-list-client.tsx +239 -0
- package/template/components/data-list-table-cells.test.tsx +22 -0
- package/template/components/data-list-table-cells.tsx +173 -0
- package/template/components/data-list-table.tsx +879 -0
- package/template/components/data-table/filter-date-calendar.tsx +38 -0
- package/template/components/data-table/filter-text-value-input.tsx +77 -0
- package/template/components/data-table/index.tsx +1612 -0
- package/template/components/data-table/pagination.tsx +256 -0
- package/template/components/data-table/types.ts +91 -0
- package/template/components/data-table/use-table-state.ts +566 -0
- package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
- package/template/components/data-view-dashboard-charts-team.tsx +968 -0
- package/template/components/data-view-dashboard-charts.tsx +1668 -0
- package/template/components/data-views/board-card-primitives.tsx +93 -0
- package/template/components/data-views/index.ts +41 -0
- package/template/components/data-views/list-page-board-card.tsx +192 -0
- package/template/components/data-views/list-page-board-template.tsx +122 -0
- package/template/components/data-views/placement-board-card.tsx +262 -0
- package/template/components/export-drawer.tsx +375 -0
- package/template/components/exxat-product-logo.tsx +453 -0
- package/template/components/form-layout-01.tsx +131 -0
- package/template/components/getting-started.tsx +625 -0
- package/template/components/key-metrics.tsx +920 -0
- package/template/components/leo-insight-indicator.tsx +364 -0
- package/template/components/leo-typing-dots.tsx +121 -0
- package/template/components/list-hub-status-badge.tsx +51 -0
- package/template/components/list-page-dashboard-charts.tsx +18 -0
- package/template/components/nav-documents.tsx +89 -0
- package/template/components/nav-main.tsx +58 -0
- package/template/components/nav-secondary.tsx +64 -0
- package/template/components/nav-user.tsx +190 -0
- package/template/components/new-placement-back-btn.tsx +28 -0
- package/template/components/new-placement-form.tsx +1066 -0
- package/template/components/onboarding/index.ts +4 -0
- package/template/components/onboarding/onboarding-01.tsx +7 -0
- package/template/components/onboarding/onboarding-02.tsx +7 -0
- package/template/components/onboarding/onboarding-03.tsx +7 -0
- package/template/components/onboarding/onboarding-04.tsx +7 -0
- package/template/components/page-header.tsx +57 -0
- package/template/components/placement-detail.tsx +438 -0
- package/template/components/placements-board-view.tsx +404 -0
- package/template/components/placements-list-view.tsx +285 -0
- package/template/components/placements-page-header.tsx +160 -0
- package/template/components/placements-table-columns.tsx +639 -0
- package/template/components/product-switcher.tsx +116 -0
- package/template/components/question-bank-board-view.tsx +205 -0
- package/template/components/question-bank-client.tsx +77 -0
- package/template/components/question-bank-list-view.tsx +59 -0
- package/template/components/question-bank-page-header.tsx +89 -0
- package/template/components/question-bank-table.tsx +586 -0
- package/template/components/rotations-empty-state.tsx +47 -0
- package/template/components/rotations-panel-activator.tsx +8 -0
- package/template/components/secondary-nav.tsx +394 -0
- package/template/components/secondary-panel.tsx +239 -0
- package/template/components/section-cards.tsx +106 -0
- package/template/components/settings-appearance-card.tsx +424 -0
- package/template/components/settings-client.tsx +537 -0
- package/template/components/settings-form-row.tsx +42 -0
- package/template/components/sidebar-auto-collapse.tsx +23 -0
- package/template/components/sidebar-auto-open.tsx +18 -0
- package/template/components/sidebar-shell.tsx +37 -0
- package/template/components/site-header.tsx +93 -0
- package/template/components/sites-all-client.tsx +154 -0
- package/template/components/sites-board-view.tsx +67 -0
- package/template/components/sites-list-view.tsx +47 -0
- package/template/components/sites-table.tsx +312 -0
- package/template/components/system-banner-slot.tsx +66 -0
- package/template/components/table-properties/column-row.tsx +90 -0
- package/template/components/table-properties/draggable-list.ts +49 -0
- package/template/components/table-properties/drawer-button.tsx +231 -0
- package/template/components/table-properties/drawer.tsx +1102 -0
- package/template/components/table-properties/filter-card.tsx +251 -0
- package/template/components/table-properties/index.ts +22 -0
- package/template/components/table-properties/sort-card.tsx +59 -0
- package/template/components/table-properties/types.ts +124 -0
- package/template/components/task-list-panel.tsx +98 -0
- package/template/components/task-priority-badge.tsx +28 -0
- package/template/components/team-board-view.tsx +114 -0
- package/template/components/team-client.tsx +93 -0
- package/template/components/team-list-view.tsx +62 -0
- package/template/components/team-page-header.tsx +92 -0
- package/template/components/team-table.tsx +525 -0
- package/template/components/templates/list-page.tsx +576 -0
- package/template/components/templates/primary-page-template.tsx +56 -0
- package/template/components/theme-color-sync.tsx +32 -0
- package/template/components/theme-provider.tsx +71 -0
- package/template/components/tinted-icon-disc.tsx +53 -0
- package/template/components/ui/ai-thinking-surface.tsx +121 -0
- package/template/components/ui/avatar.tsx +1 -0
- package/template/components/ui/badge.tsx +1 -0
- package/template/components/ui/banner.tsx +1 -0
- package/template/components/ui/breadcrumb.tsx +1 -0
- package/template/components/ui/button.tsx +1 -0
- package/template/components/ui/calendar.tsx +1 -0
- package/template/components/ui/card.tsx +1 -0
- package/template/components/ui/chart.tsx +1 -0
- package/template/components/ui/checkbox.tsx +1 -0
- package/template/components/ui/coach-mark.tsx +1 -0
- package/template/components/ui/collapsible.tsx +1 -0
- package/template/components/ui/command.tsx +1 -0
- package/template/components/ui/date-picker-field.tsx +1 -0
- package/template/components/ui/dialog.tsx +1 -0
- package/template/components/ui/dot-pattern.tsx +159 -0
- package/template/components/ui/drag-handle-grip.tsx +1 -0
- package/template/components/ui/drawer.tsx +1 -0
- package/template/components/ui/dropdown-menu.tsx +1 -0
- package/template/components/ui/field.tsx +1 -0
- package/template/components/ui/form.tsx +1 -0
- package/template/components/ui/input-group.tsx +1 -0
- package/template/components/ui/input-mask.tsx +1 -0
- package/template/components/ui/input.tsx +1 -0
- package/template/components/ui/kbd.tsx +1 -0
- package/template/components/ui/label.tsx +1 -0
- package/template/components/ui/leo-icon.tsx +726 -0
- package/template/components/ui/payment-card-fields.tsx +1 -0
- package/template/components/ui/popover.tsx +1 -0
- package/template/components/ui/radio-group.tsx +1 -0
- package/template/components/ui/select.tsx +1 -0
- package/template/components/ui/selection-tile-grid.tsx +1 -0
- package/template/components/ui/separator.tsx +1 -0
- package/template/components/ui/sheet.tsx +1 -0
- package/template/components/ui/sidebar.tsx +1 -0
- package/template/components/ui/skeleton.tsx +1 -0
- package/template/components/ui/sonner.tsx +1 -0
- package/template/components/ui/status-badge.tsx +1 -0
- package/template/components/ui/table.tsx +1 -0
- package/template/components/ui/tabs.tsx +1 -0
- package/template/components/ui/textarea.tsx +1 -0
- package/template/components/ui/tip.tsx +1 -0
- package/template/components/ui/toggle-group.tsx +1 -0
- package/template/components/ui/toggle-switch.tsx +1 -0
- package/template/components/ui/toggle.tsx +1 -0
- package/template/components/ui/tooltip.tsx +1 -0
- package/template/components/ui/view-segmented-control.tsx +1 -0
- package/template/components.json +27 -0
- package/template/contexts/chart-variant-context.tsx +35 -0
- package/template/contexts/command-menu-context.tsx +28 -0
- package/template/contexts/dashboard-view-context.tsx +35 -0
- package/template/contexts/product-context.tsx +38 -0
- package/template/contexts/system-banner-context.tsx +127 -0
- package/template/docs/command-menu-pattern.md +45 -0
- package/template/docs/data-views-pattern.md +160 -0
- package/template/ecosystem.config.cjs +20 -0
- package/template/eslint.config.mjs +18 -0
- package/template/fontawesome-subset.manifest.json +190 -0
- package/template/hooks/.gitkeep +0 -0
- package/template/hooks/use-app-theme.ts +1 -0
- package/template/hooks/use-coach-mark.ts +1 -0
- package/template/hooks/use-mobile.ts +1 -0
- package/template/hooks/use-mod-key-label.ts +1 -0
- package/template/lib/.gitkeep +0 -0
- package/template/lib/ask-leo-route-context.ts +133 -0
- package/template/lib/chart-keyboard-selection.test.ts +20 -0
- package/template/lib/chart-keyboard-selection.ts +17 -0
- package/template/lib/chart-line-dash.ts +16 -0
- package/template/lib/coach-mark-registry.ts +68 -0
- package/template/lib/command-menu-config.ts +127 -0
- package/template/lib/command-menu-search-data.ts +44 -0
- package/template/lib/conditional-rule-match.ts +32 -0
- package/template/lib/dashboard-customize-coach-mark.ts +18 -0
- package/template/lib/dashboard-layout-merge.ts +63 -0
- package/template/lib/data-list-display-options.ts +35 -0
- package/template/lib/data-list-persistence.ts +280 -0
- package/template/lib/data-list-view-surface.ts +58 -0
- package/template/lib/data-list-view.ts +29 -0
- package/template/lib/data-view-dashboard-storage.ts +101 -0
- package/template/lib/date-filter.ts +8 -0
- package/template/lib/dev-log.test.ts +28 -0
- package/template/lib/dev-log.ts +8 -0
- package/template/lib/editable-target.ts +10 -0
- package/template/lib/floating-sheet-panel.ts +72 -0
- package/template/lib/initials-from-name.ts +7 -0
- package/template/lib/list-page-table-properties.ts +52 -0
- package/template/lib/list-status-badges.ts +168 -0
- package/template/lib/logo-dev.ts +12 -0
- package/template/lib/mock/compliance-kpi.ts +61 -0
- package/template/lib/mock/compliance.ts +146 -0
- package/template/lib/mock/dashboard.ts +105 -0
- package/template/lib/mock/navigation.tsx +231 -0
- package/template/lib/mock/placements-kpi.ts +134 -0
- package/template/lib/mock/placements.ts +183 -0
- package/template/lib/mock/question-bank-kpi.ts +61 -0
- package/template/lib/mock/question-bank.ts +142 -0
- package/template/lib/mock/sites-directory.ts +16 -0
- package/template/lib/mock/sites-kpi.ts +25 -0
- package/template/lib/mock/team-kpi.ts +60 -0
- package/template/lib/mock/team.ts +118 -0
- package/template/lib/motion-ui.ts +17 -0
- package/template/lib/placement-board-card-layout.ts +79 -0
- package/template/lib/placement-lifecycle.ts +5 -0
- package/template/lib/row-height.ts +10 -0
- package/template/lib/stock-portrait.ts +11 -0
- package/template/lib/utils.test.ts +13 -0
- package/template/lib/utils.ts +1 -0
- package/template/next.config.mjs +15 -0
- package/template/package.json +83 -0
- package/template/postcss.config.mjs +8 -0
- package/template/public/.gitkeep +0 -0
- package/template/public/Illustration/Rotation.svg +74 -0
- package/template/public/avatars/user.svg +11 -0
- package/template/public/favicon/favicon.ico +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logos/exxat-one.svg +36 -0
- package/template/public/logos/exxat-prism.svg +39 -0
- package/template/public/mock-schools/emory.svg +4 -0
- package/template/public/mock-schools/rush.svg +4 -0
- package/template/scripts/fontawesome-subset-audit.mjs +190 -0
- package/template/scripts/pm2-startup-macos.sh +13 -0
- package/template/skills-lock.json +10 -0
- package/template/stores/app-store.ts +33 -0
- package/template/tests/setup.ts +1 -0
- package/template/tsconfig.json +35 -0
- package/template/types/react-payment-inputs.d.ts +19 -0
- package/template/vitest.config.ts +18 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AskLeoSidebar — app-wide right sidebar for the AI assistant.
|
|
5
|
+
* Mirrors the left sidebar behavior: slides in/out with a toggle button.
|
|
6
|
+
* Lives in the (app) layout so it persists across all pages.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from "react"
|
|
10
|
+
import { usePathname } from "next/navigation"
|
|
11
|
+
import { AnimatePresence, motion } from "motion/react"
|
|
12
|
+
import { cn } from "@/lib/utils"
|
|
13
|
+
import { Avatar, AvatarFallback, AvatarImage, AvatarLeoAssistant } from "@/components/ui/avatar"
|
|
14
|
+
import { Badge } from "@/components/ui/badge"
|
|
15
|
+
import { Button } from "@/components/ui/button"
|
|
16
|
+
import { AskLeoComposer } from "@/components/ask-leo-composer"
|
|
17
|
+
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
18
|
+
import {
|
|
19
|
+
Tooltip,
|
|
20
|
+
TooltipContent,
|
|
21
|
+
TooltipTrigger,
|
|
22
|
+
} from "@/components/ui/tooltip"
|
|
23
|
+
import { useSidebar } from "@/components/ui/sidebar"
|
|
24
|
+
import { StatusBadge } from "@/components/ui/status-badge"
|
|
25
|
+
import { AiThinkingOverlay } from "@/components/ui/ai-thinking-surface"
|
|
26
|
+
import { LeoTypingDots } from "@/components/leo-typing-dots"
|
|
27
|
+
import { LeoIcon } from "@/components/ui/leo-icon"
|
|
28
|
+
import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
|
|
29
|
+
import { ASK_LEO_GENERIC_SUGGESTIONS, getAskLeoRouteContext } from "@/lib/ask-leo-route-context"
|
|
30
|
+
import { isEditableTarget } from "@/lib/editable-target"
|
|
31
|
+
import { NAV_USER } from "@/lib/mock/navigation"
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Context — share open state with any page (e.g. "Ask Leo" buttons on cards)
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Page context that pages register with `useAskLeoPageContext` so Leo knows
|
|
39
|
+
* what the user is currently looking at. Title is shown in the welcome
|
|
40
|
+
* bubble; `suggestions` replace the generic prompt list when present; and
|
|
41
|
+
* `data` is an opaque payload the downstream API call can echo back.
|
|
42
|
+
*/
|
|
43
|
+
export interface AskLeoPageContext {
|
|
44
|
+
/** Human-readable page name, e.g. "Placements" or "Compliance dashboard". */
|
|
45
|
+
title: string
|
|
46
|
+
/** Optional one-line description ("42 active placements, 3 pending review"). */
|
|
47
|
+
description?: string
|
|
48
|
+
/** Page-specific starter prompts — replace the generic 4 when provided. */
|
|
49
|
+
suggestions?: string[]
|
|
50
|
+
/** Arbitrary payload handed to the assistant API at send time. */
|
|
51
|
+
data?: Record<string, unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface AskLeoContextValue {
|
|
55
|
+
open: boolean
|
|
56
|
+
setOpen: (open: boolean) => void
|
|
57
|
+
toggle: () => void
|
|
58
|
+
/** Open the sidebar and prefill the composer (e.g. command palette AI suggestions). */
|
|
59
|
+
openWithPrompt: (prompt: string) => void
|
|
60
|
+
/** Internal — AskLeoSidebar consumes pending text when opening. */
|
|
61
|
+
consumePendingComposerPrompt: () => string | null
|
|
62
|
+
/** Current page context (or null if no page has registered one). */
|
|
63
|
+
pageContext: AskLeoPageContext | null
|
|
64
|
+
/** Register/replace the current page's context. */
|
|
65
|
+
setPageContext: (ctx: AskLeoPageContext | null) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const AskLeoContext = React.createContext<AskLeoContextValue>({
|
|
69
|
+
open: false,
|
|
70
|
+
setOpen: () => {},
|
|
71
|
+
toggle: () => {},
|
|
72
|
+
openWithPrompt: () => {},
|
|
73
|
+
consumePendingComposerPrompt: () => null,
|
|
74
|
+
pageContext: null,
|
|
75
|
+
setPageContext: () => {},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
export function useAskLeo() {
|
|
79
|
+
return React.useContext(AskLeoContext)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pages call this at the top of their client component to tell Leo what
|
|
84
|
+
* surface the user is on. Unregisters on unmount (so route changes clear
|
|
85
|
+
* stale context). Memoize `ctx` to avoid update loops — use `React.useMemo`.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* useAskLeoPageContext(React.useMemo(() => ({
|
|
89
|
+
* title: "Placements",
|
|
90
|
+
* description: `${rows.length} rows, ${filters.active} filters active`,
|
|
91
|
+
* suggestions: [
|
|
92
|
+
* "Summarize placements ending this month",
|
|
93
|
+
* "Which sites are at capacity?",
|
|
94
|
+
* ],
|
|
95
|
+
* }), [rows.length, filters.active]))
|
|
96
|
+
*/
|
|
97
|
+
export function useAskLeoPageContext(ctx: AskLeoPageContext | null) {
|
|
98
|
+
const { setPageContext } = React.useContext(AskLeoContext)
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
setPageContext(ctx)
|
|
101
|
+
return () => setPageContext(null)
|
|
102
|
+
}, [ctx, setPageContext])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function AskLeoProvider({ children }: { children: React.ReactNode }) {
|
|
106
|
+
const [open, setOpen] = React.useState(false)
|
|
107
|
+
const [pageContext, setPageContext] = React.useState<AskLeoPageContext | null>(null)
|
|
108
|
+
const toggle = React.useCallback(() => setOpen(v => !v), [])
|
|
109
|
+
const pendingComposerPromptRef = React.useRef<string | null>(null)
|
|
110
|
+
|
|
111
|
+
const openWithPrompt = React.useCallback((prompt: string) => {
|
|
112
|
+
pendingComposerPromptRef.current = prompt
|
|
113
|
+
setOpen(true)
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
const consumePendingComposerPrompt = React.useCallback(() => {
|
|
117
|
+
const p = pendingComposerPromptRef.current
|
|
118
|
+
pendingComposerPromptRef.current = null
|
|
119
|
+
return p
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
const value = React.useMemo(
|
|
123
|
+
() => ({ open, setOpen, toggle, openWithPrompt, consumePendingComposerPrompt, pageContext, setPageContext }),
|
|
124
|
+
[open, toggle, openWithPrompt, consumePendingComposerPrompt, pageContext],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
/** ⌘⌥K / Ctrl+Alt+K — avoids browser ⌘⇧N (incognito), ⌘⇧O (bookmarks), and Ctrl+Alt+L (lock on some Linux). */
|
|
128
|
+
React.useEffect(() => {
|
|
129
|
+
function onGlobalKeyDown(e: KeyboardEvent) {
|
|
130
|
+
if (!e.altKey || (!e.metaKey && !e.ctrlKey)) return
|
|
131
|
+
if (e.key.toLowerCase() !== "k") return
|
|
132
|
+
if (isEditableTarget(e.target)) return
|
|
133
|
+
e.preventDefault()
|
|
134
|
+
toggle()
|
|
135
|
+
}
|
|
136
|
+
document.addEventListener("keydown", onGlobalKeyDown)
|
|
137
|
+
return () => document.removeEventListener("keydown", onGlobalKeyDown)
|
|
138
|
+
}, [toggle])
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<AskLeoContext.Provider value={value}>
|
|
142
|
+
{children}
|
|
143
|
+
</AskLeoContext.Provider>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type LeoThreadMessage = {
|
|
148
|
+
id: string
|
|
149
|
+
role: "user" | "assistant"
|
|
150
|
+
content: string
|
|
151
|
+
/** Assistant-only: show thinking animation until the reply is applied. */
|
|
152
|
+
pending?: boolean
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mockAssistantReply(userText: string): string {
|
|
156
|
+
return `Thanks — I received: “${userText.slice(0, 120)}${userText.length > 120 ? "…" : ""}”. Wire your assistant API here to return a real answer.`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const LEO_REPLY_DELAY_MS = 3500
|
|
160
|
+
|
|
161
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
// Sidebar component
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export function AskLeoSidebar() {
|
|
166
|
+
const { open, setOpen, consumePendingComposerPrompt, pageContext } = useAskLeo()
|
|
167
|
+
const { setOpen: setSidebarOpen, isMobile } = useSidebar()
|
|
168
|
+
const [composerValue, setComposerValue] = React.useState("")
|
|
169
|
+
const [composerExpanded, setComposerExpanded] = React.useState(false)
|
|
170
|
+
const [threadMessages, setThreadMessages] = React.useState<LeoThreadMessage[]>([])
|
|
171
|
+
const composerTextareaRef = React.useRef<HTMLTextAreaElement>(null)
|
|
172
|
+
const conversationScrollRef = React.useRef<HTMLDivElement>(null)
|
|
173
|
+
const pendingReplyTimeoutsRef = React.useRef<number[]>([])
|
|
174
|
+
|
|
175
|
+
const clearPendingReplyTimeouts = React.useCallback(() => {
|
|
176
|
+
pendingReplyTimeoutsRef.current.forEach(clearTimeout)
|
|
177
|
+
pendingReplyTimeoutsRef.current = []
|
|
178
|
+
}, [])
|
|
179
|
+
const pathname = usePathname()
|
|
180
|
+
const routeContext = React.useMemo(() => getAskLeoRouteContext(pathname), [pathname])
|
|
181
|
+
const isThinking = threadMessages.some((m) => m.pending)
|
|
182
|
+
|
|
183
|
+
const pageTitle = pageContext?.title ?? routeContext.title
|
|
184
|
+
const pageDescription = pageContext?.description ?? routeContext.description
|
|
185
|
+
const suggestions =
|
|
186
|
+
pageContext?.suggestions && pageContext.suggestions.length > 0
|
|
187
|
+
? pageContext.suggestions
|
|
188
|
+
: routeContext.suggestions ?? []
|
|
189
|
+
|
|
190
|
+
const suggestionChips =
|
|
191
|
+
suggestions.length > 0 ? suggestions : ASK_LEO_GENERIC_SUGGESTIONS
|
|
192
|
+
|
|
193
|
+
const appendUserTurn = React.useCallback((text: string) => {
|
|
194
|
+
const trimmed = text.trim()
|
|
195
|
+
if (!trimmed) return
|
|
196
|
+
const userId = crypto.randomUUID()
|
|
197
|
+
const asstId = crypto.randomUUID()
|
|
198
|
+
setThreadMessages((prev) => [
|
|
199
|
+
...prev,
|
|
200
|
+
{ id: userId, role: "user", content: trimmed },
|
|
201
|
+
{ id: asstId, role: "assistant", content: "", pending: true },
|
|
202
|
+
])
|
|
203
|
+
const tid = window.setTimeout(() => {
|
|
204
|
+
setThreadMessages((prev) =>
|
|
205
|
+
prev.map((m) =>
|
|
206
|
+
m.id === asstId && m.role === "assistant"
|
|
207
|
+
? { ...m, content: mockAssistantReply(trimmed), pending: false }
|
|
208
|
+
: m,
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
pendingReplyTimeoutsRef.current = pendingReplyTimeoutsRef.current.filter((t) => t !== tid)
|
|
212
|
+
}, LEO_REPLY_DELAY_MS)
|
|
213
|
+
pendingReplyTimeoutsRef.current.push(tid)
|
|
214
|
+
}, [])
|
|
215
|
+
|
|
216
|
+
React.useEffect(() => {
|
|
217
|
+
if (!open) return
|
|
218
|
+
const el = conversationScrollRef.current
|
|
219
|
+
if (!el) return
|
|
220
|
+
requestAnimationFrame(() => {
|
|
221
|
+
el.scrollTop = el.scrollHeight
|
|
222
|
+
})
|
|
223
|
+
}, [threadMessages, open])
|
|
224
|
+
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
if (!open) {
|
|
227
|
+
clearPendingReplyTimeouts()
|
|
228
|
+
setThreadMessages([])
|
|
229
|
+
setComposerValue("")
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
const pending = consumePendingComposerPrompt()
|
|
233
|
+
if (pending !== null) {
|
|
234
|
+
setComposerValue(pending)
|
|
235
|
+
queueMicrotask(() => composerTextareaRef.current?.focus())
|
|
236
|
+
} else {
|
|
237
|
+
setComposerValue("")
|
|
238
|
+
}
|
|
239
|
+
}, [open, consumePendingComposerPrompt, clearPendingReplyTimeouts])
|
|
240
|
+
|
|
241
|
+
React.useEffect(() => () => clearPendingReplyTimeouts(), [clearPendingReplyTimeouts])
|
|
242
|
+
|
|
243
|
+
// Collapse main sidebar when Ask Leo opens, expand when it closes
|
|
244
|
+
const prevOpen = React.useRef(open)
|
|
245
|
+
React.useEffect(() => {
|
|
246
|
+
if (open && !prevOpen.current) setSidebarOpen(false)
|
|
247
|
+
if (!open && prevOpen.current) setSidebarOpen(true)
|
|
248
|
+
prevOpen.current = open
|
|
249
|
+
}, [open, setSidebarOpen])
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<>
|
|
253
|
+
{/* Mobile/zoomed-in: tap-outside scrim — WCAG aria-hidden, closes on click */}
|
|
254
|
+
{isMobile && open && (
|
|
255
|
+
<div
|
|
256
|
+
aria-hidden="true"
|
|
257
|
+
className="fixed inset-0 z-40"
|
|
258
|
+
onClick={() => setOpen(false)}
|
|
259
|
+
/>
|
|
260
|
+
)}
|
|
261
|
+
<aside
|
|
262
|
+
aria-label="Ask Leo — AI assistant"
|
|
263
|
+
data-state={open ? "open" : "closed"}
|
|
264
|
+
className={cn(
|
|
265
|
+
"flex flex-col overflow-hidden",
|
|
266
|
+
isMobile
|
|
267
|
+
? open
|
|
268
|
+
// Mobile/zoomed: fixed floating panel on the right, same inset style as left sidebar
|
|
269
|
+
? "fixed z-50 right-2 top-2 h-[calc(100dvh-1rem)] w-[min(20rem,calc(100vw-1rem))] rounded-2xl border border-border/60 shadow-2xl ring-1 ring-border/20"
|
|
270
|
+
: "hidden"
|
|
271
|
+
: cn(
|
|
272
|
+
"transition-[width,margin,opacity] duration-200 ease-linear",
|
|
273
|
+
open
|
|
274
|
+
? "relative w-64 md:w-80 shrink-0 self-start m-2 mx-2 min-h-0 h-[min(calc(100dvh-2rem),800px)] overflow-hidden rounded-xl border border-sidebar-border/80 shadow-[0_18px_48px_-16px_rgba(15,23,42,0.2),0_8px_20px_-10px_rgba(15,23,42,0.12)] ring-1 ring-sidebar-border dark:shadow-[0_22px_56px_-12px_rgba(0,0,0,0.5),0_10px_28px_-12px_rgba(0,0,0,0.35)] md:sticky md:top-2 md:ml-0 md:h-[calc(100dvh-1.25rem)]"
|
|
275
|
+
: "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none",
|
|
276
|
+
)
|
|
277
|
+
)}
|
|
278
|
+
style={
|
|
279
|
+
open
|
|
280
|
+
? {
|
|
281
|
+
background:
|
|
282
|
+
"linear-gradient(180deg, color-mix(in oklch, var(--brand-color) 4%, var(--background)) 0%, color-mix(in oklch, var(--brand-color) 8%, var(--background)) 100%)",
|
|
283
|
+
}
|
|
284
|
+
: undefined
|
|
285
|
+
}
|
|
286
|
+
>
|
|
287
|
+
<AiThinkingOverlay active={open && isThinking} />
|
|
288
|
+
{/* min-w only when open — avoids flex min-width:auto stealing width / hit-testing when closed */}
|
|
289
|
+
<div
|
|
290
|
+
className={cn(
|
|
291
|
+
"relative z-[1] flex min-h-0 min-w-0 flex-1 flex-col",
|
|
292
|
+
open ? "min-w-0" : "hidden min-w-0 w-0"
|
|
293
|
+
)}
|
|
294
|
+
>
|
|
295
|
+
{/* Header */}
|
|
296
|
+
<div className="flex items-start justify-between gap-2 px-4 py-3 shrink-0">
|
|
297
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
298
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
299
|
+
<i className="fa-duotone fa-solid fa-star-christmas text-brand shrink-0" aria-hidden="true" />
|
|
300
|
+
<h1
|
|
301
|
+
className="m-0 text-lg font-semibold tracking-tight leading-tight text-sidebar-foreground truncate"
|
|
302
|
+
style={{ fontFamily: "var(--font-heading)" }}
|
|
303
|
+
>
|
|
304
|
+
Ask Leo
|
|
305
|
+
</h1>
|
|
306
|
+
<StatusBadge status="beta" size="xs" className="shrink-0" />
|
|
307
|
+
</div>
|
|
308
|
+
<p className="text-[11px] leading-snug text-sidebar-foreground/60">
|
|
309
|
+
Powered by AI · responses may vary
|
|
310
|
+
</p>
|
|
311
|
+
</div>
|
|
312
|
+
<Tooltip>
|
|
313
|
+
<TooltipTrigger asChild>
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
onClick={() => setOpen(false)}
|
|
317
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
318
|
+
aria-label="Close Ask Leo"
|
|
319
|
+
>
|
|
320
|
+
<i className="fa-light fa-xmark text-xs" aria-hidden="true" />
|
|
321
|
+
</button>
|
|
322
|
+
</TooltipTrigger>
|
|
323
|
+
<TooltipContent side="bottom" className="flex max-w-xs flex-wrap items-center gap-1.5 text-xs">
|
|
324
|
+
<span>Close Ask Leo</span>
|
|
325
|
+
<AskLeoShortcutKbds />
|
|
326
|
+
</TooltipContent>
|
|
327
|
+
</Tooltip>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{/* Conversation scrolls behind composer; composer is absolutely pinned to the bottom. */}
|
|
331
|
+
<div className="relative min-h-0 flex-1">
|
|
332
|
+
<div
|
|
333
|
+
ref={conversationScrollRef}
|
|
334
|
+
className={cn(
|
|
335
|
+
"absolute inset-0 scroll-smooth overflow-y-auto overflow-x-hidden overscroll-y-contain px-4 pt-4 pb-28 [-webkit-overflow-scrolling:touch]",
|
|
336
|
+
threadMessages.length === 0 && "flex items-center justify-center",
|
|
337
|
+
)}
|
|
338
|
+
role="log"
|
|
339
|
+
aria-label="Conversation with Leo"
|
|
340
|
+
aria-live="polite"
|
|
341
|
+
>
|
|
342
|
+
<div
|
|
343
|
+
className={cn(
|
|
344
|
+
"flex w-full min-w-0 flex-col gap-4",
|
|
345
|
+
threadMessages.length === 0 && "items-center",
|
|
346
|
+
)}
|
|
347
|
+
>
|
|
348
|
+
{threadMessages.length === 0 ? (
|
|
349
|
+
<>
|
|
350
|
+
<LeoIcon
|
|
351
|
+
variant="interactive"
|
|
352
|
+
size="xl"
|
|
353
|
+
className="[animation:leo-chip-in_520ms_cubic-bezier(0.22,1,0.36,1)_both]"
|
|
354
|
+
/>
|
|
355
|
+
<ul className="m-0 flex list-none flex-wrap justify-center gap-2 p-0" aria-label="Suggested prompts">
|
|
356
|
+
{suggestionChips.map((q, i) => (
|
|
357
|
+
<li
|
|
358
|
+
key={`${i}-${q.slice(0, 24)}`}
|
|
359
|
+
className="max-w-full list-none [animation:leo-chip-in_420ms_cubic-bezier(0.22,1,0.36,1)_both]"
|
|
360
|
+
style={{ animationDelay: `${i * 70}ms` }}
|
|
361
|
+
>
|
|
362
|
+
<Badge
|
|
363
|
+
asChild
|
|
364
|
+
variant="outline"
|
|
365
|
+
className="h-auto min-h-8 max-w-full items-stretch whitespace-normal rounded-4xl border-border/90 bg-card px-0 py-0 font-normal text-card-foreground shadow-sm transition-transform duration-150 hover:-translate-y-0.5 dark:border-border dark:bg-card"
|
|
366
|
+
>
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={() => appendUserTurn(q)}
|
|
370
|
+
className="inline-flex min-h-8 w-full max-w-full cursor-pointer text-start text-xs leading-snug transition-colors hover:bg-sidebar-accent/70 hover:text-sidebar-foreground dark:hover:bg-sidebar-accent/40"
|
|
371
|
+
>
|
|
372
|
+
<span className="line-clamp-4 px-3 py-2">{q}</span>
|
|
373
|
+
</button>
|
|
374
|
+
</Badge>
|
|
375
|
+
</li>
|
|
376
|
+
))}
|
|
377
|
+
</ul>
|
|
378
|
+
</>
|
|
379
|
+
) : (
|
|
380
|
+
threadMessages.map((m) =>
|
|
381
|
+
m.role === "user" ? (
|
|
382
|
+
<div key={m.id} className="flex w-full min-w-0 flex-row-reverse gap-3">
|
|
383
|
+
<Avatar size="sm" className="mt-0.5 shrink-0">
|
|
384
|
+
<AvatarImage src={NAV_USER.avatar} alt="" />
|
|
385
|
+
<AvatarFallback className="bg-secondary text-xs font-medium text-secondary-foreground">
|
|
386
|
+
{NAV_USER.name.slice(0, 2).toUpperCase()}
|
|
387
|
+
</AvatarFallback>
|
|
388
|
+
</Avatar>
|
|
389
|
+
{/* Reserve avatar (~2rem) + gap-3 so max-w 100% does not include sibling width (overflow). */}
|
|
390
|
+
<div className="min-w-0 max-w-[min(18rem,calc(100%-3rem))] break-words rounded-lg rounded-tr-sm bg-primary px-3 py-2.5 text-start text-sm leading-relaxed text-primary-foreground shadow-sm">
|
|
391
|
+
{m.content}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
) : (
|
|
395
|
+
<div key={m.id} className="flex w-full min-w-0 gap-3">
|
|
396
|
+
<AvatarLeoAssistant className="mt-0.5 shrink-0" />
|
|
397
|
+
<div className="min-w-0 flex-1 pt-0.5 text-start text-sm leading-relaxed text-sidebar-foreground">
|
|
398
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
399
|
+
{m.pending ? (
|
|
400
|
+
<LeoTypingDots key="thinking" />
|
|
401
|
+
) : (
|
|
402
|
+
<motion.p
|
|
403
|
+
key="content"
|
|
404
|
+
className="m-0 break-words"
|
|
405
|
+
initial={{ opacity: 0, y: 4 }}
|
|
406
|
+
animate={{ opacity: 1, y: 0 }}
|
|
407
|
+
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
|
408
|
+
>
|
|
409
|
+
{m.content}
|
|
410
|
+
</motion.p>
|
|
411
|
+
)}
|
|
412
|
+
</AnimatePresence>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-b from-transparent to-sidebar/90 px-3 pb-4 pt-10 sm:pb-5">
|
|
422
|
+
<div className="pointer-events-auto">
|
|
423
|
+
<div
|
|
424
|
+
className={cn(
|
|
425
|
+
"mx-1 min-w-0 max-w-full border border-border/80 bg-card/95 shadow-[0_22px_56px_-14px_rgba(15,23,42,0.28),0_10px_28px_-10px_rgba(15,23,42,0.18),0_2px_8px_-2px_rgba(15,23,42,0.08)] backdrop-blur-md supports-[backdrop-filter]:bg-card/92 dark:border-border/55 dark:shadow-[0_24px_64px_-12px_rgba(0,0,0,0.62),0_12px_32px_-12px_rgba(0,0,0,0.42),0_4px_12px_-4px_rgba(0,0,0,0.35)]",
|
|
426
|
+
composerExpanded ? "rounded-2xl p-1.5" : "rounded-full px-1 py-1",
|
|
427
|
+
)}
|
|
428
|
+
>
|
|
429
|
+
<AskLeoComposer
|
|
430
|
+
ref={composerTextareaRef}
|
|
431
|
+
value={composerValue}
|
|
432
|
+
onChange={setComposerValue}
|
|
433
|
+
onSubmit={appendUserTurn}
|
|
434
|
+
onExpandedChange={setComposerExpanded}
|
|
435
|
+
placeholder="Ask Leo anything…"
|
|
436
|
+
className="[&_form>div]:rounded-none [&_form>div]:border-0 [&_form>div]:bg-transparent [&_form>div]:shadow-none"
|
|
437
|
+
/>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
</aside>
|
|
444
|
+
</>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
449
|
+
// Toggle button — can be placed anywhere (e.g. in SiteHeader or floating)
|
|
450
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Keyboard shortcut hint for Ask Leo (⌘⌥K / Ctrl+Alt+K).
|
|
454
|
+
*
|
|
455
|
+
* • `variant="tile"` (default) — three separate tile kbds, for use in tooltip
|
|
456
|
+
* content / standalone surfaces where the tile chrome is welcome.
|
|
457
|
+
* • `variant="bare"` — single inline kbd with no background/border that
|
|
458
|
+
* inherits the parent's currentColor (see Kbd "bare" variant). Use this
|
|
459
|
+
* whenever the kbds are rendered INSIDE a button (e.g. primary Ask Leo
|
|
460
|
+
* button in a popover footer). Matches the `<Kbd variant="bare">` pattern
|
|
461
|
+
* used by the Next / Back buttons in the new placement flow.
|
|
462
|
+
*/
|
|
463
|
+
export function AskLeoShortcutKbds({
|
|
464
|
+
className,
|
|
465
|
+
variant = "tile",
|
|
466
|
+
}: {
|
|
467
|
+
className?: string
|
|
468
|
+
variant?: "tile" | "bare"
|
|
469
|
+
}) {
|
|
470
|
+
const mod = useModKeyLabel()
|
|
471
|
+
const alt = useAltKeyLabel()
|
|
472
|
+
if (variant === "bare") {
|
|
473
|
+
return (
|
|
474
|
+
<KbdGroup className={className}>
|
|
475
|
+
<Kbd variant="bare">{mod}{alt}K</Kbd>
|
|
476
|
+
</KbdGroup>
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
return (
|
|
480
|
+
<KbdGroup className={className}>
|
|
481
|
+
<Kbd>{mod}</Kbd>
|
|
482
|
+
<Kbd>{alt}</Kbd>
|
|
483
|
+
<Kbd>K</Kbd>
|
|
484
|
+
</KbdGroup>
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function AskLeoToggle({ className }: { className?: string }) {
|
|
489
|
+
const { toggle, open } = useAskLeo()
|
|
490
|
+
return (
|
|
491
|
+
<Tooltip>
|
|
492
|
+
<TooltipTrigger asChild>
|
|
493
|
+
<Button
|
|
494
|
+
variant="outline"
|
|
495
|
+
size="sm"
|
|
496
|
+
onClick={toggle}
|
|
497
|
+
className={cn("gap-1.5 md:aspect-auto aspect-square", open && "bg-brand/10 border-brand/30 text-brand", className)}
|
|
498
|
+
>
|
|
499
|
+
<i className="fa-duotone fa-solid fa-star-christmas text-xs text-brand" aria-hidden="true" />
|
|
500
|
+
<span className="hidden md:inline">Ask Leo</span>
|
|
501
|
+
</Button>
|
|
502
|
+
</TooltipTrigger>
|
|
503
|
+
<TooltipContent side="bottom" className="flex flex-wrap items-center gap-1.5">
|
|
504
|
+
<span>{open ? "Close Ask Leo" : "Ask Leo"}</span>
|
|
505
|
+
<AskLeoShortcutKbds />
|
|
506
|
+
</TooltipContent>
|
|
507
|
+
</Tooltip>
|
|
508
|
+
)
|
|
509
|
+
}
|