deepspace 0.7.0 → 0.8.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/CHANGELOG.md +30 -0
- package/README.md +22 -8
- package/dist/cli.js +10306 -5256
- package/dist/cli.js.map +1 -1
- package/dist/feature-installer.js +638 -0
- package/dist/feature-installer.js.map +1 -0
- package/dist/index.d.ts +107 -117
- package/dist/index.js +610 -779
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +403 -167
- package/dist/server.js +498 -367
- package/dist/server.js.map +1 -1
- package/dist/testing.d.ts +115 -0
- package/dist/testing.js +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +397 -160
- package/dist/worker.js +506 -367
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -0
- package/features/ai-chat/src/AiChatPage.tsx +2 -3
- package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
- package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
- package/features/ai-chat/src/ChatPanel.tsx +158 -862
- package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
- package/features/canvas/src/CanvasListPage.tsx +1 -1
- package/features/docs/feature.json +4 -8
- package/features/docs/src/DocsEditorPage.tsx +33 -326
- package/features/docs/src/DocsListPage.tsx +33 -29
- package/features/docs/src/InviteDialog.tsx +11 -40
- package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
- package/features/docs/src/docs-library-types.ts +28 -3
- package/features/docs/src/docs-ui.css +1 -4
- package/features/docs/src/use-docs-presence-access.ts +330 -0
- package/features/file-attachments/feature.json +2 -2
- package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
- package/features/file-attachments/src/useFilePreview.ts +54 -68
- package/features/file-manager/src/FileManagerPage.tsx +1 -1
- package/features/integration-test/src/integration.spec.ts +1 -1
- package/features/items/feature.json +1 -5
- package/features/items/src/ItemsPage.tsx +207 -135
- package/features/landing/feature.json +3 -0
- package/features/landing/src/LandingPage.tsx +4 -18
- package/features/leaderboard/feature.json +1 -5
- package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
- package/features/messaging/feature.json +0 -4
- package/features/messaging/src/ChatMultiPage.tsx +1 -3
- package/features/messaging/src/ChatPage.tsx +1 -3
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
- package/features/search-bar/feature.json +8 -3
- package/features/search-bar/src/SearchOverlay.tsx +108 -252
- package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
- package/features/search-bar/src/search-model.ts +223 -0
- package/features/tasks/src/TasksPage.tsx +3 -3
- package/features/topbar/src/Topbar.tsx +0 -17
- package/package.json +1 -2
- package/features/docs/src/docs-favorites.ts +0 -18
- package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
- package/features/file-attachments/src/attachments-constants.ts +0 -9
- package/features/items/src/items-constants.ts +0 -10
- package/features/leaderboard/src/leaderboard-constants.ts +0 -21
- package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
- package/scripts/add-feature.cjs +0 -807
|
@@ -57,10 +57,6 @@
|
|
|
57
57
|
"src": "src/components/chat/MessageActionSheet.tsx",
|
|
58
58
|
"dest": "src/components/messaging/chat/MessageActionSheet.tsx"
|
|
59
59
|
},
|
|
60
|
-
{
|
|
61
|
-
"src": "src/components/chat/ReactionPicker.tsx",
|
|
62
|
-
"dest": "src/components/messaging/chat/ReactionPicker.tsx"
|
|
63
|
-
},
|
|
64
60
|
{
|
|
65
61
|
"src": "src/components/chat-multi/ChannelSidebar.tsx",
|
|
66
62
|
"dest": "src/components/messaging/chat-multi/ChannelSidebar.tsx"
|
|
@@ -27,13 +27,11 @@ import { ChannelSettingsPanel } from '../components/messaging/chat-multi/Channel
|
|
|
27
27
|
|
|
28
28
|
interface ChatMultiPageProps {
|
|
29
29
|
schemas?: CollectionSchema[]
|
|
30
|
-
appId?: string
|
|
31
30
|
className?: string
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
export default function ChatMultiPage({
|
|
35
34
|
schemas = messagingSchemas,
|
|
36
|
-
appId = '',
|
|
37
35
|
className,
|
|
38
36
|
}: ChatMultiPageProps) {
|
|
39
37
|
const {
|
|
@@ -158,7 +156,7 @@ export default function ChatMultiPage({
|
|
|
158
156
|
}`}
|
|
159
157
|
>
|
|
160
158
|
{activeChannelId ? (
|
|
161
|
-
<RecordScope key={activeChannelId} roomId={`chat:${activeChannelId}`} schemas={schemas}
|
|
159
|
+
<RecordScope key={activeChannelId} roomId={`chat:${activeChannelId}`} schemas={schemas}>
|
|
162
160
|
{/* Multi-channel header with back button on mobile */}
|
|
163
161
|
<div
|
|
164
162
|
data-testid="multi-chat-header"
|
|
@@ -14,14 +14,12 @@ import { MessageList } from '../components/messaging/chat/MessageList'
|
|
|
14
14
|
|
|
15
15
|
interface ChatPageProps {
|
|
16
16
|
schemas?: CollectionSchema[]
|
|
17
|
-
appId?: string
|
|
18
17
|
channelName?: string
|
|
19
18
|
className?: string
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export default function ChatPage({
|
|
23
22
|
schemas = messagingSchemas,
|
|
24
|
-
appId = '',
|
|
25
23
|
channelName = 'general',
|
|
26
24
|
className,
|
|
27
25
|
}: ChatPageProps) {
|
|
@@ -72,7 +70,7 @@ export default function ChatPage({
|
|
|
72
70
|
return (
|
|
73
71
|
<div className={`flex flex-col h-full ${className ?? ''}`} data-testid="chat-page">
|
|
74
72
|
<ChatHeader channelId={channelId} />
|
|
75
|
-
<RecordScope roomId={`chat:${channelId}`} schemas={schemas}
|
|
73
|
+
<RecordScope roomId={`chat:${channelId}`} schemas={schemas}>
|
|
76
74
|
<MessageList channelId={channelId} />
|
|
77
75
|
</RecordScope>
|
|
78
76
|
</div>
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useEffect, useCallback, useState } from 'react'
|
|
8
|
-
import { EMOJI_LIST } from './ReactionPicker'
|
|
9
8
|
import type { MessageRect } from './MessageItem'
|
|
10
9
|
|
|
11
10
|
interface MessageActionSheetProps {
|
|
@@ -22,6 +21,7 @@ interface MessageActionSheetProps {
|
|
|
22
21
|
|
|
23
22
|
const ACTION_BAR_HEIGHT = 44
|
|
24
23
|
const VIEWPORT_PADDING = 12
|
|
24
|
+
const EMOJI_LIST = ['👍', '👎', '😄', '🎉', '❤️', '🚀', '👀', '🔥', '💯', '🤔', '😢', '🙏']
|
|
25
25
|
|
|
26
26
|
export function MessageActionSheet({
|
|
27
27
|
visible,
|
|
@@ -41,7 +41,7 @@ export function MessageActionSheet({
|
|
|
41
41
|
action()
|
|
42
42
|
onClose()
|
|
43
43
|
},
|
|
44
|
-
[onClose]
|
|
44
|
+
[onClose],
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
useEffect(() => {
|
|
@@ -84,8 +84,18 @@ export function MessageActionSheet({
|
|
|
84
84
|
className="shrink-0 p-1.5 rounded-lg active:bg-muted text-muted-foreground active:text-foreground transition-colors"
|
|
85
85
|
title="Edit"
|
|
86
86
|
>
|
|
87
|
-
<svg
|
|
88
|
-
|
|
87
|
+
<svg
|
|
88
|
+
className="w-5 h-5"
|
|
89
|
+
fill="none"
|
|
90
|
+
viewBox="0 0 24 24"
|
|
91
|
+
stroke="currentColor"
|
|
92
|
+
strokeWidth={1.5}
|
|
93
|
+
>
|
|
94
|
+
<path
|
|
95
|
+
strokeLinecap="round"
|
|
96
|
+
strokeLinejoin="round"
|
|
97
|
+
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
98
|
+
/>
|
|
89
99
|
</svg>
|
|
90
100
|
</button>
|
|
91
101
|
<button
|
|
@@ -94,8 +104,18 @@ export function MessageActionSheet({
|
|
|
94
104
|
className="shrink-0 p-1.5 rounded-lg active:bg-destructive/10 text-muted-foreground active:text-destructive transition-colors"
|
|
95
105
|
title="Delete"
|
|
96
106
|
>
|
|
97
|
-
<svg
|
|
98
|
-
|
|
107
|
+
<svg
|
|
108
|
+
className="w-5 h-5"
|
|
109
|
+
fill="none"
|
|
110
|
+
viewBox="0 0 24 24"
|
|
111
|
+
stroke="currentColor"
|
|
112
|
+
strokeWidth={1.5}
|
|
113
|
+
>
|
|
114
|
+
<path
|
|
115
|
+
strokeLinecap="round"
|
|
116
|
+
strokeLinejoin="round"
|
|
117
|
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
118
|
+
/>
|
|
99
119
|
</svg>
|
|
100
120
|
</button>
|
|
101
121
|
<div className="shrink-0 w-px h-6 bg-border mx-0.5" />
|
|
@@ -107,8 +127,18 @@ export function MessageActionSheet({
|
|
|
107
127
|
className="shrink-0 p-1.5 rounded-lg active:bg-muted text-muted-foreground active:text-foreground transition-colors"
|
|
108
128
|
title="Reply"
|
|
109
129
|
>
|
|
110
|
-
<svg
|
|
111
|
-
|
|
130
|
+
<svg
|
|
131
|
+
className="w-5 h-5"
|
|
132
|
+
fill="none"
|
|
133
|
+
viewBox="0 0 24 24"
|
|
134
|
+
stroke="currentColor"
|
|
135
|
+
strokeWidth={1.5}
|
|
136
|
+
>
|
|
137
|
+
<path
|
|
138
|
+
strokeLinecap="round"
|
|
139
|
+
strokeLinejoin="round"
|
|
140
|
+
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
|
141
|
+
/>
|
|
112
142
|
</svg>
|
|
113
143
|
</button>
|
|
114
144
|
<div className="shrink-0 w-px h-6 bg-border mx-0.5" />
|
|
@@ -143,9 +173,7 @@ export function MessageActionSheet({
|
|
|
143
173
|
onClick={(e) => e.stopPropagation()}
|
|
144
174
|
>
|
|
145
175
|
{layout === 'above' && (
|
|
146
|
-
<div className="absolute bottom-full mb-2 left-0 w-full">
|
|
147
|
-
{actionBar}
|
|
148
|
-
</div>
|
|
176
|
+
<div className="absolute bottom-full mb-2 left-0 w-full">{actionBar}</div>
|
|
149
177
|
)}
|
|
150
178
|
|
|
151
179
|
<div
|
|
@@ -157,11 +185,7 @@ export function MessageActionSheet({
|
|
|
157
185
|
</p>
|
|
158
186
|
</div>
|
|
159
187
|
|
|
160
|
-
{layout === 'below' &&
|
|
161
|
-
<div className="mt-2 w-full">
|
|
162
|
-
{actionBar}
|
|
163
|
-
</div>
|
|
164
|
-
)}
|
|
188
|
+
{layout === 'below' && <div className="mt-2 w-full">{actionBar}</div>}
|
|
165
189
|
</div>
|
|
166
190
|
</div>
|
|
167
191
|
)
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
"name": "Search Bar",
|
|
4
4
|
"category": "data",
|
|
5
5
|
"description": "Drop-in InlineSearch/SearchOverlay for app search; use instead of raw SearchInput when showing or filtering results",
|
|
6
|
-
"details": "Use this before hand-rolling search over local arrays, feeds, tables, pickers, or RecordRoom records. Installs
|
|
6
|
+
"details": "Use this before hand-rolling search over local arrays, feeds, tables, pickers, or RecordRoom records. Installs focused UI and search-model modules for inline or overlay search, ranking, keyboard navigation, grouped results, async search, highlighting, and optional recent searches. It does not add a route or demo page; import the installed modules into the page you are building.",
|
|
7
7
|
"files": [
|
|
8
|
+
{
|
|
9
|
+
"src": "src/search-model.ts",
|
|
10
|
+
"dest": "src/components/search/search-model.ts"
|
|
11
|
+
},
|
|
8
12
|
{
|
|
9
13
|
"src": "src/SearchOverlay.tsx",
|
|
10
14
|
"dest": "src/components/search/SearchOverlay.tsx"
|
|
@@ -12,7 +16,8 @@
|
|
|
12
16
|
],
|
|
13
17
|
"requires": [],
|
|
14
18
|
"instructions": [
|
|
15
|
-
"Installs src/components/search/SearchOverlay.tsx
|
|
19
|
+
"Installs src/components/search/SearchOverlay.tsx for UI and src/components/search/search-model.ts for ranking, async search, highlighting, and recent-history behavior. It adds no route.",
|
|
20
|
+
"Import InlineSearch, SearchOverlay, or SearchResultList from @/components/search/SearchOverlay; import useSearchIndex, useAsyncSearch, or getHighlightedParts from @/components/search/search-model.",
|
|
16
21
|
"Use InlineSearch + useSearchIndex for in-page filtering (lists, feeds, tables, pickers, RecordRoom records); use SearchOverlay for global/command-style search.",
|
|
17
22
|
"Use the raw SearchInput primitive only for a bare input with no result list, ranking, keyboard navigation, or selection handling."
|
|
18
23
|
],
|
|
@@ -31,5 +36,5 @@
|
|
|
31
36
|
"Use mocked or debounced async providers during prototyping before wiring live integration calls.",
|
|
32
37
|
"For privacy-sensitive search contexts, set showRecentSearches={false} or use a scoped recentStorageKey per page or account context."
|
|
33
38
|
],
|
|
34
|
-
"example": "import { InlineSearch
|
|
39
|
+
"example": "import { InlineSearch } from '@/components/search/SearchOverlay'\nimport { useSearchIndex } from '@/components/search/search-model'\n\nconst results = useSearchIndex({\n items: posts,\n query,\n getText: (post) => [post.title, post.body, post.authorName],\n})\n\n<InlineSearch\n query={query}\n onQueryChange={setQuery}\n items={results.map((post) => ({\n id: post.id,\n title: post.title,\n subtitle: post.authorName,\n description: post.body,\n }))}\n onSelect={(item) => openPost(item.id)}\n placeholder=\"Search posts...\"\n/>"
|
|
35
40
|
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { Button, EmptyState, SearchInput, cn } from '@/components/ui'
|
|
3
3
|
import { Loader2 } from 'lucide-react'
|
|
4
|
+
import {
|
|
5
|
+
createRecentStorageKey,
|
|
6
|
+
readRecentSearches,
|
|
7
|
+
rememberRecentItem,
|
|
8
|
+
rememberRecentQuery,
|
|
9
|
+
type RecentSearchEntry,
|
|
10
|
+
} from './search-model'
|
|
11
|
+
|
|
12
|
+
const recentQueryMarker = Symbol('recent-query')
|
|
4
13
|
|
|
5
14
|
export interface SearchItem {
|
|
6
15
|
id: string
|
|
@@ -13,12 +22,14 @@ export interface SearchItem {
|
|
|
13
22
|
disabled?: boolean
|
|
14
23
|
}
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
match: boolean
|
|
25
|
+
interface RecentQueryItem extends SearchItem {
|
|
26
|
+
[recentQueryMarker]: string
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
export type SearchResultRenderer<T extends SearchItem = SearchItem> = (
|
|
29
|
+
export type SearchResultRenderer<T extends SearchItem = SearchItem> = (
|
|
30
|
+
item: T,
|
|
31
|
+
query: string,
|
|
32
|
+
) => ReactNode
|
|
22
33
|
|
|
23
34
|
interface SearchBaseProps<T extends SearchItem = SearchItem> {
|
|
24
35
|
query: string
|
|
@@ -57,115 +68,6 @@ export interface InlineSearchProps<T extends SearchItem = SearchItem> extends Se
|
|
|
57
68
|
maxResultsHeightClassName?: string
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
export interface UseSearchIndexOptions<T> {
|
|
61
|
-
items: T[]
|
|
62
|
-
query: string
|
|
63
|
-
getText: (item: T) => Array<string | number | null | undefined>
|
|
64
|
-
limit?: number
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function useSearchIndex<T>({ items, query, getText, limit = 20 }: UseSearchIndexOptions<T>): T[] {
|
|
68
|
-
return useMemo(() => {
|
|
69
|
-
const normalizedQuery = normalize(query)
|
|
70
|
-
if (!normalizedQuery) return items.slice(0, limit)
|
|
71
|
-
|
|
72
|
-
const scored = items
|
|
73
|
-
.map((item, index) => {
|
|
74
|
-
const haystack = getText(item)
|
|
75
|
-
.filter((value): value is string | number => value !== null && value !== undefined)
|
|
76
|
-
.map(String)
|
|
77
|
-
.join(' ')
|
|
78
|
-
const score = scoreText(haystack, normalizedQuery)
|
|
79
|
-
return { item, index, score }
|
|
80
|
-
})
|
|
81
|
-
.filter((entry) => entry.score > 0)
|
|
82
|
-
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
83
|
-
|
|
84
|
-
return scored.slice(0, limit).map((entry) => entry.item)
|
|
85
|
-
}, [getText, items, limit, query])
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function getHighlightedParts(text: string, query: string): HighlightedPart[] {
|
|
89
|
-
const normalizedQuery = normalize(query)
|
|
90
|
-
if (!text || !normalizedQuery) return [{ text, match: false }]
|
|
91
|
-
|
|
92
|
-
const normalizedText = text.toLowerCase()
|
|
93
|
-
const parts: HighlightedPart[] = []
|
|
94
|
-
let cursor = 0
|
|
95
|
-
let index = normalizedText.indexOf(normalizedQuery)
|
|
96
|
-
|
|
97
|
-
while (index !== -1) {
|
|
98
|
-
if (index > cursor) {
|
|
99
|
-
parts.push({ text: text.slice(cursor, index), match: false })
|
|
100
|
-
}
|
|
101
|
-
parts.push({ text: text.slice(index, index + normalizedQuery.length), match: true })
|
|
102
|
-
cursor = index + normalizedQuery.length
|
|
103
|
-
index = normalizedText.indexOf(normalizedQuery, cursor)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (cursor < text.length) {
|
|
107
|
-
parts.push({ text: text.slice(cursor), match: false })
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return parts.length > 0 ? parts : [{ text, match: false }]
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface UseAsyncSearchOptions<T> {
|
|
114
|
-
query: string
|
|
115
|
-
search: (query: string) => Promise<T[]>
|
|
116
|
-
minLength?: number
|
|
117
|
-
delayMs?: number
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function useAsyncSearch<T>({
|
|
121
|
-
query,
|
|
122
|
-
search,
|
|
123
|
-
minLength = 2,
|
|
124
|
-
delayMs = 250,
|
|
125
|
-
}: UseAsyncSearchOptions<T>) {
|
|
126
|
-
const [items, setItems] = useState<T[]>([])
|
|
127
|
-
const [loading, setLoading] = useState(false)
|
|
128
|
-
const [error, setError] = useState<string | null>(null)
|
|
129
|
-
const requestId = useRef(0)
|
|
130
|
-
|
|
131
|
-
useEffect(() => {
|
|
132
|
-
const trimmed = query.trim()
|
|
133
|
-
requestId.current += 1
|
|
134
|
-
const current = requestId.current
|
|
135
|
-
|
|
136
|
-
if (trimmed.length < minLength) {
|
|
137
|
-
setItems([])
|
|
138
|
-
setLoading(false)
|
|
139
|
-
setError(null)
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
setLoading(true)
|
|
144
|
-
setError(null)
|
|
145
|
-
|
|
146
|
-
const timer = window.setTimeout(() => {
|
|
147
|
-
search(trimmed)
|
|
148
|
-
.then((results) => {
|
|
149
|
-
if (requestId.current !== current) return
|
|
150
|
-
setItems(results)
|
|
151
|
-
setError(null)
|
|
152
|
-
})
|
|
153
|
-
.catch((err) => {
|
|
154
|
-
if (requestId.current !== current) return
|
|
155
|
-
setItems([])
|
|
156
|
-
setError(err instanceof Error ? err.message : 'Search failed')
|
|
157
|
-
})
|
|
158
|
-
.finally(() => {
|
|
159
|
-
if (requestId.current === current) setLoading(false)
|
|
160
|
-
})
|
|
161
|
-
}, delayMs)
|
|
162
|
-
|
|
163
|
-
return () => window.clearTimeout(timer)
|
|
164
|
-
}, [delayMs, minLength, query, search])
|
|
165
|
-
|
|
166
|
-
return { items, loading, error }
|
|
167
|
-
}
|
|
168
|
-
|
|
169
71
|
export function SearchOverlay<T extends SearchItem = SearchItem>({
|
|
170
72
|
open,
|
|
171
73
|
onOpenChange,
|
|
@@ -193,29 +95,49 @@ export function SearchOverlay<T extends SearchItem = SearchItem>({
|
|
|
193
95
|
}: SearchOverlayProps<T>) {
|
|
194
96
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
195
97
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
196
|
-
const [
|
|
197
|
-
const resolvedRecentStorageKey =
|
|
98
|
+
const [recentSearches, setRecentSearches] = useState<RecentSearchEntry[]>([])
|
|
99
|
+
const resolvedRecentStorageKey =
|
|
100
|
+
recentStorageKey ?? createRecentStorageKey(title, placeholder, triggerLabel)
|
|
101
|
+
const recentItems = useMemo(() => {
|
|
102
|
+
const itemsById = new Map(items.map((item) => [item.id, item]))
|
|
103
|
+
return recentSearches.flatMap((recent, index): SearchItem[] => {
|
|
104
|
+
if (recent.kind === 'query') {
|
|
105
|
+
const queryItem: RecentQueryItem = {
|
|
106
|
+
id: `deepspace-recent-query:${index}`,
|
|
107
|
+
title: recent.query,
|
|
108
|
+
subtitle: 'Search query',
|
|
109
|
+
group: 'Recent',
|
|
110
|
+
keywords: [recent.query],
|
|
111
|
+
[recentQueryMarker]: recent.query,
|
|
112
|
+
}
|
|
113
|
+
return [queryItem]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const item = itemsById.get(recent.itemId)
|
|
117
|
+
return item ? [{ ...item, group: 'Recent' }] : []
|
|
118
|
+
})
|
|
119
|
+
}, [items, recentSearches])
|
|
198
120
|
const showRecents = showRecentSearches && query.trim() === '' && recentItems.length > 0
|
|
199
|
-
const visibleItems = showRecents
|
|
200
|
-
? recentItems.map((item) => ({ ...item, group: 'Recent' }))
|
|
201
|
-
: items
|
|
121
|
+
const visibleItems: SearchItem[] = showRecents ? recentItems : items
|
|
202
122
|
const canSubmitSearch = Boolean(onSearchSubmit && query.trim())
|
|
203
123
|
|
|
204
124
|
function submitSearch(rawQuery: string) {
|
|
205
125
|
const trimmed = rawQuery.trim()
|
|
206
126
|
if (!trimmed || !onSearchSubmit) return
|
|
207
|
-
|
|
208
|
-
|
|
127
|
+
if (showRecentSearches) {
|
|
128
|
+
setRecentSearches(rememberRecentQuery(resolvedRecentStorageKey, trimmed, maxRecentItems))
|
|
129
|
+
}
|
|
209
130
|
onSearchSubmit(trimmed)
|
|
210
131
|
onOpenChange(false)
|
|
211
132
|
}
|
|
212
133
|
|
|
213
134
|
useEffect(() => {
|
|
214
135
|
if (!open) return
|
|
215
|
-
window.setTimeout(() => inputRef.current?.focus(), 0)
|
|
136
|
+
const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0)
|
|
216
137
|
if (showRecentSearches) {
|
|
217
|
-
|
|
138
|
+
setRecentSearches(readRecentSearches(resolvedRecentStorageKey, maxRecentItems))
|
|
218
139
|
}
|
|
140
|
+
return () => window.clearTimeout(focusTimer)
|
|
219
141
|
}, [maxRecentItems, open, resolvedRecentStorageKey, showRecentSearches])
|
|
220
142
|
|
|
221
143
|
useEffect(() => {
|
|
@@ -238,16 +160,24 @@ export function SearchOverlay<T extends SearchItem = SearchItem>({
|
|
|
238
160
|
</Button>
|
|
239
161
|
|
|
240
162
|
{open && (
|
|
241
|
-
<div
|
|
163
|
+
<div
|
|
164
|
+
className="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm px-3 py-4 sm:py-16"
|
|
165
|
+
data-testid="search-overlay"
|
|
166
|
+
>
|
|
242
167
|
<div
|
|
243
168
|
ref={panelRef}
|
|
169
|
+
role="dialog"
|
|
170
|
+
aria-modal="true"
|
|
171
|
+
aria-label={title}
|
|
244
172
|
className="mx-auto flex max-h-[min(720px,calc(100vh-2rem))] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
|
|
245
173
|
>
|
|
246
174
|
<div className="border-b border-border p-4">
|
|
247
175
|
<div className="mb-3 flex items-start justify-between gap-4">
|
|
248
176
|
<div>
|
|
249
177
|
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
|
250
|
-
{description &&
|
|
178
|
+
{description && (
|
|
179
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
180
|
+
)}
|
|
251
181
|
</div>
|
|
252
182
|
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
|
253
183
|
Close
|
|
@@ -280,14 +210,24 @@ export function SearchOverlay<T extends SearchItem = SearchItem>({
|
|
|
280
210
|
items={visibleItems}
|
|
281
211
|
query={showRecents ? '' : query}
|
|
282
212
|
onSelect={(item) => {
|
|
283
|
-
if (showRecents
|
|
284
|
-
|
|
285
|
-
|
|
213
|
+
if (showRecents) {
|
|
214
|
+
if (isRecentQueryItem(item)) {
|
|
215
|
+
onQueryChange(item[recentQueryMarker])
|
|
216
|
+
submitSearch(item[recentQueryMarker])
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const currentItem = items.find((candidate) => candidate.id === item.id)
|
|
221
|
+
if (!currentItem) return
|
|
222
|
+
onSelect(currentItem)
|
|
223
|
+
onOpenChange(false)
|
|
286
224
|
return
|
|
287
225
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
226
|
+
|
|
227
|
+
if (showRecentSearches) {
|
|
228
|
+
setRecentSearches(
|
|
229
|
+
rememberRecentItem(resolvedRecentStorageKey, item.id, maxRecentItems),
|
|
230
|
+
)
|
|
291
231
|
}
|
|
292
232
|
onSelect(item as T)
|
|
293
233
|
onOpenChange(false)
|
|
@@ -298,9 +238,21 @@ export function SearchOverlay<T extends SearchItem = SearchItem>({
|
|
|
298
238
|
emptyTitle={emptyTitle}
|
|
299
239
|
emptyDescription={emptyDescription}
|
|
300
240
|
onSubmitQuery={onSearchSubmit ? submitSearch : undefined}
|
|
301
|
-
renderTitle={
|
|
302
|
-
|
|
303
|
-
|
|
241
|
+
renderTitle={
|
|
242
|
+
showRecents
|
|
243
|
+
? undefined
|
|
244
|
+
: (renderTitle as SearchResultRenderer<SearchItem> | undefined)
|
|
245
|
+
}
|
|
246
|
+
renderSubtitle={
|
|
247
|
+
showRecents
|
|
248
|
+
? undefined
|
|
249
|
+
: (renderSubtitle as SearchResultRenderer<SearchItem> | undefined)
|
|
250
|
+
}
|
|
251
|
+
renderDescription={
|
|
252
|
+
showRecents
|
|
253
|
+
? undefined
|
|
254
|
+
: (renderDescription as SearchResultRenderer<SearchItem> | undefined)
|
|
255
|
+
}
|
|
304
256
|
/>
|
|
305
257
|
</div>
|
|
306
258
|
</div>
|
|
@@ -347,7 +299,11 @@ export function InlineSearch<T extends SearchItem = SearchItem>({
|
|
|
347
299
|
return (
|
|
348
300
|
<div ref={wrapperRef} className={cn('relative', className)} data-testid="inline-search">
|
|
349
301
|
{dimContent && showPanel && (
|
|
350
|
-
<div
|
|
302
|
+
<div
|
|
303
|
+
className="fixed inset-0 z-30 bg-background/50 backdrop-blur-[2px]"
|
|
304
|
+
aria-hidden="true"
|
|
305
|
+
data-testid="inline-search-dimmer"
|
|
306
|
+
/>
|
|
351
307
|
)}
|
|
352
308
|
|
|
353
309
|
<div className="relative z-40">
|
|
@@ -374,10 +330,13 @@ export function InlineSearch<T extends SearchItem = SearchItem>({
|
|
|
374
330
|
/>
|
|
375
331
|
|
|
376
332
|
{showPanel && (
|
|
377
|
-
<div
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
333
|
+
<div
|
|
334
|
+
className={cn(
|
|
335
|
+
'absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-xl border border-border bg-card shadow-xl',
|
|
336
|
+
maxResultsHeightClassName,
|
|
337
|
+
)}
|
|
338
|
+
data-testid="inline-search-panel"
|
|
339
|
+
>
|
|
381
340
|
<SearchPanel
|
|
382
341
|
items={items}
|
|
383
342
|
query={query}
|
|
@@ -485,7 +444,10 @@ function SearchPanel<T extends SearchItem = SearchItem>({
|
|
|
485
444
|
tabIndex={-1}
|
|
486
445
|
>
|
|
487
446
|
{loading ? (
|
|
488
|
-
<div
|
|
447
|
+
<div
|
|
448
|
+
className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground"
|
|
449
|
+
data-testid="search-loading"
|
|
450
|
+
>
|
|
489
451
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
490
452
|
Searching...
|
|
491
453
|
</div>
|
|
@@ -556,7 +518,7 @@ export function SearchResultList<T extends SearchItem = SearchItem>({
|
|
|
556
518
|
className={cn(
|
|
557
519
|
'flex w-full items-start justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors',
|
|
558
520
|
active ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
|
|
559
|
-
item.disabled && 'cursor-not-allowed opacity-50'
|
|
521
|
+
item.disabled && 'cursor-not-allowed opacity-50',
|
|
560
522
|
)}
|
|
561
523
|
data-testid="search-result"
|
|
562
524
|
data-active={active ? 'true' : 'false'}
|
|
@@ -576,7 +538,9 @@ export function SearchResultList<T extends SearchItem = SearchItem>({
|
|
|
576
538
|
</span>
|
|
577
539
|
)}
|
|
578
540
|
</span>
|
|
579
|
-
{item.meta &&
|
|
541
|
+
{item.meta && (
|
|
542
|
+
<span className="shrink-0 text-xs text-muted-foreground">{item.meta}</span>
|
|
543
|
+
)}
|
|
580
544
|
</button>
|
|
581
545
|
)
|
|
582
546
|
})}
|
|
@@ -587,97 +551,6 @@ export function SearchResultList<T extends SearchItem = SearchItem>({
|
|
|
587
551
|
)
|
|
588
552
|
}
|
|
589
553
|
|
|
590
|
-
export function toSearchItem(item: SearchItem): SearchItem {
|
|
591
|
-
return item
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function createRecentStorageKey(title: string, placeholder: string, triggerLabel: string) {
|
|
595
|
-
const scope = [title, placeholder, triggerLabel]
|
|
596
|
-
.map((value) => value.trim().toLowerCase())
|
|
597
|
-
.find(Boolean) ?? 'search'
|
|
598
|
-
const normalizedScope = scope.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'search'
|
|
599
|
-
return `deepspace:search-bar:recent:${normalizedScope}`
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function readRecentSearchItems(storageKey: string, maxItems: number): SearchItem[] {
|
|
603
|
-
try {
|
|
604
|
-
const raw = window.localStorage.getItem(storageKey)
|
|
605
|
-
if (!raw) return []
|
|
606
|
-
const parsed = JSON.parse(raw)
|
|
607
|
-
if (!Array.isArray(parsed)) return []
|
|
608
|
-
return parsed
|
|
609
|
-
.filter(isStoredSearchItem)
|
|
610
|
-
.slice(0, maxItems)
|
|
611
|
-
.map((item) => ({ ...item, group: 'Recent' }))
|
|
612
|
-
} catch {
|
|
613
|
-
return []
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function writeRecentSearchItem(storageKey: string, item: SearchItem, maxItems: number): SearchItem[] {
|
|
618
|
-
const recentItem = toStoredSearchItem(item)
|
|
619
|
-
const next = [
|
|
620
|
-
recentItem,
|
|
621
|
-
...readRecentSearchItems(storageKey, maxItems).filter((existing) => existing.id !== recentItem.id),
|
|
622
|
-
].slice(0, maxItems)
|
|
623
|
-
|
|
624
|
-
try {
|
|
625
|
-
window.localStorage.setItem(storageKey, JSON.stringify(next))
|
|
626
|
-
} catch {
|
|
627
|
-
// Ignore storage failures; search should still work without persistence.
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
return next.map((storedItem) => ({ ...storedItem, group: 'Recent' }))
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function writeRecentSearchQuery(storageKey: string, query: string, maxItems: number): SearchItem[] {
|
|
634
|
-
const recentItem = toStoredSearchQuery(query)
|
|
635
|
-
const next = [
|
|
636
|
-
recentItem,
|
|
637
|
-
...readRecentSearchItems(storageKey, maxItems).filter((existing) => existing.id !== recentItem.id),
|
|
638
|
-
].slice(0, maxItems)
|
|
639
|
-
|
|
640
|
-
try {
|
|
641
|
-
window.localStorage.setItem(storageKey, JSON.stringify(next))
|
|
642
|
-
} catch {
|
|
643
|
-
// Ignore storage failures; search should still work without persistence.
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
return next.map((storedItem) => ({ ...storedItem, group: 'Recent' }))
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
function toStoredSearchItem(item: SearchItem): SearchItem {
|
|
650
|
-
return {
|
|
651
|
-
id: item.id,
|
|
652
|
-
title: item.title,
|
|
653
|
-
subtitle: item.subtitle,
|
|
654
|
-
description: item.description,
|
|
655
|
-
group: 'Recent',
|
|
656
|
-
keywords: item.keywords,
|
|
657
|
-
disabled: item.disabled,
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
function toStoredSearchQuery(query: string): SearchItem {
|
|
662
|
-
return {
|
|
663
|
-
id: `search-query:${normalize(query)}`,
|
|
664
|
-
title: query,
|
|
665
|
-
subtitle: 'Search query',
|
|
666
|
-
group: 'Recent',
|
|
667
|
-
keywords: [query],
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
function isRecentSearchQueryItem(item: SearchItem) {
|
|
672
|
-
return item.id.startsWith('search-query:')
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function isStoredSearchItem(value: unknown): value is SearchItem {
|
|
676
|
-
if (!value || typeof value !== 'object') return false
|
|
677
|
-
const candidate = value as Partial<SearchItem>
|
|
678
|
-
return typeof candidate.id === 'string' && typeof candidate.title === 'string'
|
|
679
|
-
}
|
|
680
|
-
|
|
681
554
|
function groupItems<T extends SearchItem>(items: T[]): Array<[string, T[]]> {
|
|
682
555
|
const groups = new Map<string, T[]>()
|
|
683
556
|
for (const item of items) {
|
|
@@ -688,23 +561,6 @@ function groupItems<T extends SearchItem>(items: T[]): Array<[string, T[]]> {
|
|
|
688
561
|
return Array.from(groups.entries())
|
|
689
562
|
}
|
|
690
563
|
|
|
691
|
-
function
|
|
692
|
-
return
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function scoreText(value: string, query: string) {
|
|
696
|
-
const text = normalize(value)
|
|
697
|
-
if (!text || !query) return 0
|
|
698
|
-
if (text === query) return 100
|
|
699
|
-
if (text.startsWith(query)) return 80
|
|
700
|
-
if (text.includes(` ${query}`)) return 60
|
|
701
|
-
if (text.includes(query)) return 40
|
|
702
|
-
|
|
703
|
-
const terms = query.split(/\s+/).filter(Boolean)
|
|
704
|
-
if (terms.length > 1 && terms.every((term) => text.includes(term))) return 20
|
|
705
|
-
return 0
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
export function ResultCountBadge({ count }: { count: number }) {
|
|
709
|
-
return <Badge variant="secondary" size="sm">{count} results</Badge>
|
|
564
|
+
function isRecentQueryItem(item: SearchItem): item is RecentQueryItem {
|
|
565
|
+
return recentQueryMarker in item
|
|
710
566
|
}
|