deepspace 0.26.0 → 0.26.2
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 +45 -0
- package/dist/{chunk-LPXWXF6R.js → chunk-S2OGAK6U.js} +2 -2
- package/dist/{chunk-LPXWXF6R.js.map → chunk-S2OGAK6U.js.map} +1 -1
- package/dist/cli.js +82 -45
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +1 -1
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +1 -1
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +26 -434
- package/dist/index.js +99 -864
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +4 -41
- package/dist/schema.js +2 -242
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +6 -162
- package/dist/server.js +13 -281
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +4 -160
- package/dist/worker.js +15 -281
- package/dist/worker.js.map +1 -1
- package/features/messaging/feature.json +3 -43
- package/features/messaging/src/ChatPage.tsx +12 -16
- package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
- package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
- package/features/messaging/src/components/chat/MessageList.tsx +57 -40
- package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
- package/features/messaging/src/hooks/useChatChannel.ts +18 -23
- package/features/messaging/src/messaging-schema.ts +0 -2
- package/features/search-bar/feature.json +9 -20
- package/features/search-bar/src/InlineSearch.tsx +101 -0
- package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
- package/features/search-bar/src/search-model.ts +2 -149
- package/package.json +1 -1
- package/features/messaging/src/ChatMultiPage.tsx +0 -298
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
- package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
- package/features/search-bar/src/SearchOverlay.tsx +0 -566
- package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
- package/features/sidebar/feature.json +0 -19
- package/features/sidebar/src/AppSidebar.tsx +0 -558
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useChatChannel — Find or create a default chat channel
|
|
2
|
+
* useChatChannel — Find or create a default public chat channel.
|
|
3
3
|
*
|
|
4
|
-
* Encapsulates
|
|
5
|
-
* know about the channels collection. On first mount
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Encapsulates default-channel discovery so consuming components never need
|
|
5
|
+
* to know about the channels collection. On first mount, a member or admin
|
|
6
|
+
* creates the "general" public channel when none exists. Channel membership
|
|
7
|
+
* remains optional and is not used as a read-access gate.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* const { channelId, status } = useChatChannel()
|
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { useEffect, useRef } from 'react'
|
|
16
|
-
import { useUser } from 'deepspace'
|
|
16
|
+
import { useUser, isWriterRole } from 'deepspace'
|
|
17
17
|
import { useChannels } from 'deepspace'
|
|
18
|
-
import { useChannelMembers } from 'deepspace'
|
|
19
18
|
import type { Channel } from 'deepspace'
|
|
20
19
|
import type { RecordData } from 'deepspace'
|
|
21
20
|
|
|
@@ -24,7 +23,10 @@ const DEFAULT_CHANNEL_NAME = 'general'
|
|
|
24
23
|
export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
|
|
25
24
|
const { user } = useUser()
|
|
26
25
|
const { channels, status, create } = useChannels()
|
|
27
|
-
|
|
26
|
+
// Keyed by channel name, not a boolean: the same mounted hook can be
|
|
27
|
+
// repointed at another channel, which needs its own one-shot create.
|
|
28
|
+
const initializedFor = useRef<string | null>(null)
|
|
29
|
+
const canCreateChannel = isWriterRole(user?.role)
|
|
28
30
|
|
|
29
31
|
// Nothing enforces name uniqueness server-side, so two first visitors can
|
|
30
32
|
// race the one-shot init below and BOTH create the default channel. With a
|
|
@@ -43,32 +45,25 @@ export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
|
|
|
43
45
|
a.createdAt.localeCompare(b.createdAt) || a.recordId.localeCompare(b.recordId),
|
|
44
46
|
)[0]
|
|
45
47
|
|
|
46
|
-
const { isMember, join, status: membersStatus } = useChannelMembers(defaultChannel?.recordId)
|
|
47
|
-
|
|
48
48
|
// One-shot initialization: create the default channel if it doesn't exist.
|
|
49
49
|
useEffect(() => {
|
|
50
|
-
if (!
|
|
51
|
-
|
|
50
|
+
if (!canCreateChannel || status !== 'ready' || initializedFor.current === channelName) return
|
|
51
|
+
initializedFor.current = channelName
|
|
52
52
|
|
|
53
53
|
if (!defaultChannel) {
|
|
54
|
-
create({
|
|
54
|
+
void create({
|
|
55
55
|
name: channelName,
|
|
56
|
-
type: 'public',
|
|
57
56
|
description: 'Default chat channel',
|
|
57
|
+
}).catch(() => {
|
|
58
|
+
// A not-ready write already reaches RecordProvider.onWriteError. Allow
|
|
59
|
+
// a later ready render to retry instead of leaving initialization stuck.
|
|
60
|
+
initializedFor.current = null
|
|
58
61
|
})
|
|
59
62
|
}
|
|
60
|
-
}, [
|
|
61
|
-
|
|
62
|
-
// Wait for the members query to be ready before auto-joining.
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (!user || !defaultChannel || isMember || membersStatus !== 'ready') return
|
|
65
|
-
join()
|
|
66
|
-
}, [user, defaultChannel, isMember, membersStatus, join])
|
|
63
|
+
}, [canCreateChannel, status, defaultChannel, create, channelName])
|
|
67
64
|
|
|
68
65
|
return {
|
|
69
66
|
channelId: defaultChannel?.recordId,
|
|
70
67
|
status,
|
|
71
|
-
isMember,
|
|
72
|
-
join,
|
|
73
68
|
}
|
|
74
69
|
}
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
MESSAGES_SCHEMA,
|
|
12
12
|
REACTIONS_SCHEMA,
|
|
13
13
|
CHANNEL_MEMBERS_SCHEMA,
|
|
14
|
-
CHANNEL_INVITATIONS_SCHEMA,
|
|
15
14
|
READ_RECEIPTS_SCHEMA,
|
|
16
15
|
} from 'deepspace/schema'
|
|
17
16
|
|
|
@@ -20,6 +19,5 @@ export const messagingSchemas: CollectionSchema[] = [
|
|
|
20
19
|
MESSAGES_SCHEMA,
|
|
21
20
|
REACTIONS_SCHEMA,
|
|
22
21
|
CHANNEL_MEMBERS_SCHEMA,
|
|
23
|
-
CHANNEL_INVITATIONS_SCHEMA,
|
|
24
22
|
READ_RECEIPTS_SCHEMA,
|
|
25
23
|
]
|
|
@@ -2,39 +2,28 @@
|
|
|
2
2
|
"id": "search-bar",
|
|
3
3
|
"name": "Search Bar",
|
|
4
4
|
"category": "data",
|
|
5
|
-
"description": "
|
|
6
|
-
"details": "
|
|
5
|
+
"description": "Inline local search with lightweight ranking and highlighting",
|
|
6
|
+
"details": "A controlled InlineSearch component and a small local ranking hook. The feature does not own overlays, persistence, history, networking, navigation, or app state.",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"src": "src/search-model.ts",
|
|
10
10
|
"dest": "src/components/search/search-model.ts"
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
|
-
"src": "src/
|
|
14
|
-
"dest": "src/components/search/
|
|
13
|
+
"src": "src/InlineSearch.tsx",
|
|
14
|
+
"dest": "src/components/search/InlineSearch.tsx"
|
|
15
15
|
}
|
|
16
16
|
],
|
|
17
17
|
"requires": [],
|
|
18
18
|
"instructions": [
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"Use InlineSearch + useSearchIndex for in-page filtering (lists, feeds, tables, pickers, RecordRoom records); use SearchOverlay for global/command-style search.",
|
|
22
|
-
"Use the raw SearchInput primitive only for a bare input with no result list, ranking, keyboard navigation, or selection handling."
|
|
19
|
+
"Import InlineSearch from @/components/search/InlineSearch and useSearchIndex from @/components/search/search-model.",
|
|
20
|
+
"Keep source data, async fetching, navigation, and persistence in the page that owns the search experience."
|
|
23
21
|
],
|
|
24
22
|
"agentNotes": [
|
|
25
|
-
"Use
|
|
26
|
-
"Pass onSearchSubmit when pressing Enter should navigate to or render a full search-results page for the typed query; result clicks still call onSelect.",
|
|
27
|
-
"Ask the user which placement they prefer only when both overlay and inline search are plausible and the page context does not make the placement obvious.",
|
|
28
|
-
"Use SearchOverlay's recentStorageKey, maxRecentItems, and showRecentSearches props when recent selections or submitted text queries should appear before the user types; keep recent searches off inline controls unless the surrounding UI has room for them.",
|
|
29
|
-
"Use getHighlightedParts with renderTitle, renderSubtitle, or renderDescription when matching text should be highlighted; leave highlighting to app-specific renderers so colors, emphasis, and density match the local design."
|
|
23
|
+
"Use this feature for local list, feed, table, picker, or RecordRoom filtering. Build a product-specific overlay only when the app actually needs one."
|
|
30
24
|
],
|
|
31
25
|
"patterns": [
|
|
32
|
-
"
|
|
33
|
-
"For RecordRoom data, call useQuery in the page and pass records mapped to SearchItem[] into InlineSearch or SearchOverlay.",
|
|
34
|
-
"For overlay search that previews matching items but routes Enter to a results page, pass onSearchSubmit={(query) => navigate(`/search?q=${encodeURIComponent(query)}`)} and keep onSelect for direct result clicks.",
|
|
35
|
-
"Keep selections app-specific: create or update records in the onSelect handler only after the user explicitly chooses a result.",
|
|
36
|
-
"Use mocked or debounced async providers during prototyping before wiring live integration calls.",
|
|
37
|
-
"For privacy-sensitive search contexts, set showRecentSearches={false} or use a scoped recentStorageKey per page or account context."
|
|
26
|
+
"Call useSearchIndex in the page, map results to SearchItem[], and pass the controlled query and selection handler to InlineSearch."
|
|
38
27
|
],
|
|
39
|
-
"example": "import { InlineSearch } from '@/components/search/
|
|
28
|
+
"example": "import { InlineSearch } from '@/components/search/InlineSearch'\nimport { useSearchIndex } from '@/components/search/search-model'\n\nconst results = useSearchIndex({ items: posts, query, getText: (post) => [post.title, post.body] })\n\n<InlineSearch query={query} onQueryChange={setQuery} items={results.map((post) => ({ id: post.id, title: post.title, description: post.body }))} onSelect={(item) => openPost(item.id)} />"
|
|
40
29
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { EmptyState, SearchInput, cn } from '@/components/ui'
|
|
3
|
+
import { Loader2 } from 'lucide-react'
|
|
4
|
+
import { getHighlightedParts } from './search-model'
|
|
5
|
+
|
|
6
|
+
export interface SearchItem {
|
|
7
|
+
id: string
|
|
8
|
+
title: string
|
|
9
|
+
subtitle?: string
|
|
10
|
+
description?: string
|
|
11
|
+
meta?: ReactNode
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface InlineSearchProps<T extends SearchItem = SearchItem> {
|
|
16
|
+
query: string
|
|
17
|
+
onQueryChange: (query: string) => void
|
|
18
|
+
items: T[]
|
|
19
|
+
onSelect: (item: T) => void
|
|
20
|
+
placeholder?: string
|
|
21
|
+
label?: string
|
|
22
|
+
loading?: boolean
|
|
23
|
+
error?: string | null
|
|
24
|
+
emptyTitle?: string
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A small, controlled search input and result list for one page section. */
|
|
29
|
+
export function InlineSearch<T extends SearchItem = SearchItem>({
|
|
30
|
+
query,
|
|
31
|
+
onQueryChange,
|
|
32
|
+
items,
|
|
33
|
+
onSelect,
|
|
34
|
+
placeholder = 'Search...',
|
|
35
|
+
label,
|
|
36
|
+
loading = false,
|
|
37
|
+
error = null,
|
|
38
|
+
emptyTitle = 'No matches',
|
|
39
|
+
className,
|
|
40
|
+
}: InlineSearchProps<T>) {
|
|
41
|
+
return (
|
|
42
|
+
<section className={cn('space-y-2', className)}>
|
|
43
|
+
{label && <label className="text-sm font-medium text-foreground">{label}</label>}
|
|
44
|
+
<SearchInput
|
|
45
|
+
value={query}
|
|
46
|
+
onChange={(event) => onQueryChange(event.target.value)}
|
|
47
|
+
onClear={() => onQueryChange('')}
|
|
48
|
+
placeholder={placeholder}
|
|
49
|
+
aria-label={label ?? placeholder}
|
|
50
|
+
/>
|
|
51
|
+
|
|
52
|
+
{loading ? (
|
|
53
|
+
<div className="flex items-center gap-2 p-3 text-sm text-muted-foreground">
|
|
54
|
+
<Loader2 className="size-4 animate-spin" /> Searching...
|
|
55
|
+
</div>
|
|
56
|
+
) : error ? (
|
|
57
|
+
<p role="alert" className="p-3 text-sm text-destructive">
|
|
58
|
+
{error}
|
|
59
|
+
</p>
|
|
60
|
+
) : items.length === 0 ? (
|
|
61
|
+
<EmptyState title={emptyTitle} description="Try another search term." />
|
|
62
|
+
) : (
|
|
63
|
+
<ul className="max-h-80 overflow-y-auto rounded-lg border border-border bg-card">
|
|
64
|
+
{items.map((item) => (
|
|
65
|
+
<li key={item.id}>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
disabled={item.disabled}
|
|
69
|
+
onClick={() => onSelect(item)}
|
|
70
|
+
className="flex w-full items-start justify-between gap-3 border-b border-border p-3 text-left last:border-b-0 hover:bg-muted/60 disabled:cursor-not-allowed disabled:opacity-50"
|
|
71
|
+
>
|
|
72
|
+
<span className="min-w-0">
|
|
73
|
+
<span className="block text-sm font-medium text-foreground">
|
|
74
|
+
<HighlightedText text={item.title} query={query} />
|
|
75
|
+
</span>
|
|
76
|
+
{item.subtitle && (
|
|
77
|
+
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
78
|
+
<HighlightedText text={item.subtitle} query={query} />
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
{item.description && (
|
|
82
|
+
<span className="mt-1 line-clamp-2 block text-sm text-muted-foreground">
|
|
83
|
+
{item.description}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
</span>
|
|
87
|
+
{item.meta}
|
|
88
|
+
</button>
|
|
89
|
+
</li>
|
|
90
|
+
))}
|
|
91
|
+
</ul>
|
|
92
|
+
)}
|
|
93
|
+
</section>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function HighlightedText({ text, query }: { text: string; query: string }) {
|
|
98
|
+
return getHighlightedParts(text, query).map((part, index) =>
|
|
99
|
+
part.match ? <mark key={index}>{part.text}</mark> : <span key={index}>{part.text}</span>,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { getHighlightedParts, useSearchIndex } from '../search-model'
|
|
4
|
+
|
|
5
|
+
const items = [
|
|
6
|
+
{ id: 'middle', title: 'Notes about sheep' },
|
|
7
|
+
{ id: 'exact', title: 'Sheep' },
|
|
8
|
+
{ id: 'prefix', title: 'Sheep counter' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
function ranked(query: string, limit?: number) {
|
|
12
|
+
let result = items
|
|
13
|
+
function Probe() {
|
|
14
|
+
result = useSearchIndex({ items, query, getText: (item) => [item.title], limit })
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
renderToStaticMarkup(<Probe />)
|
|
18
|
+
return result
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('inline search model', () => {
|
|
22
|
+
it('ranks exact and prefix matches before contained matches', () => {
|
|
23
|
+
expect(ranked('sheep').map((item) => item.id)).toEqual(['exact', 'prefix', 'middle'])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('applies the result limit to searches and empty queries', () => {
|
|
27
|
+
expect(ranked('sheep', 2)).toHaveLength(2)
|
|
28
|
+
expect(ranked('', 2)).toEqual(items.slice(0, 2))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('highlights every case-insensitive match without dropping text', () => {
|
|
32
|
+
expect(getHighlightedParts('Sheep sheep', 'sheep')).toEqual([
|
|
33
|
+
{ text: 'Sheep', match: true },
|
|
34
|
+
{ text: ' ', match: false },
|
|
35
|
+
{ text: 'sheep', match: true },
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
2
|
|
|
3
3
|
export interface HighlightedPart {
|
|
4
4
|
text: string
|
|
@@ -12,6 +12,7 @@ export interface UseSearchIndexOptions<T> {
|
|
|
12
12
|
limit?: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Rank a local list without owning app state, persistence, or networking. */
|
|
15
16
|
export function useSearchIndex<T>({
|
|
16
17
|
items,
|
|
17
18
|
query,
|
|
@@ -58,154 +59,6 @@ export function getHighlightedParts(text: string, query: string): HighlightedPar
|
|
|
58
59
|
return parts.length > 0 ? parts : [{ text, match: false }]
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
export interface UseAsyncSearchOptions<T> {
|
|
62
|
-
query: string
|
|
63
|
-
search: (query: string) => Promise<T[]>
|
|
64
|
-
minLength?: number
|
|
65
|
-
delayMs?: number
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function useAsyncSearch<T>({
|
|
69
|
-
query,
|
|
70
|
-
search,
|
|
71
|
-
minLength = 2,
|
|
72
|
-
delayMs = 250,
|
|
73
|
-
}: UseAsyncSearchOptions<T>) {
|
|
74
|
-
const [items, setItems] = useState<T[]>([])
|
|
75
|
-
const [loading, setLoading] = useState(false)
|
|
76
|
-
const [error, setError] = useState<string | null>(null)
|
|
77
|
-
const requestId = useRef(0)
|
|
78
|
-
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
const trimmed = query.trim()
|
|
81
|
-
const currentRequest = ++requestId.current
|
|
82
|
-
const requiredLength = Number.isFinite(minLength) ? Math.max(0, Math.floor(minLength)) : 2
|
|
83
|
-
const waitMs = Number.isFinite(delayMs)
|
|
84
|
-
? Math.min(60_000, Math.max(0, Math.floor(delayMs)))
|
|
85
|
-
: 250
|
|
86
|
-
|
|
87
|
-
if (trimmed.length < requiredLength) {
|
|
88
|
-
setItems([])
|
|
89
|
-
setLoading(false)
|
|
90
|
-
setError(null)
|
|
91
|
-
return
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
setLoading(true)
|
|
95
|
-
setError(null)
|
|
96
|
-
|
|
97
|
-
const timer = window.setTimeout(() => {
|
|
98
|
-
void search(trimmed)
|
|
99
|
-
.then((results) => {
|
|
100
|
-
if (requestId.current !== currentRequest) return
|
|
101
|
-
setItems(results)
|
|
102
|
-
setError(null)
|
|
103
|
-
})
|
|
104
|
-
.catch((searchError) => {
|
|
105
|
-
if (requestId.current !== currentRequest) return
|
|
106
|
-
setItems([])
|
|
107
|
-
setError(searchError instanceof Error ? searchError.message : 'Search failed')
|
|
108
|
-
})
|
|
109
|
-
.finally(() => {
|
|
110
|
-
if (requestId.current === currentRequest) setLoading(false)
|
|
111
|
-
})
|
|
112
|
-
}, waitMs)
|
|
113
|
-
|
|
114
|
-
return () => {
|
|
115
|
-
window.clearTimeout(timer)
|
|
116
|
-
if (requestId.current === currentRequest) requestId.current += 1
|
|
117
|
-
}
|
|
118
|
-
}, [delayMs, minLength, query, search])
|
|
119
|
-
|
|
120
|
-
return { items, loading, error }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export type RecentSearchEntry = { kind: 'item'; itemId: string } | { kind: 'query'; query: string }
|
|
124
|
-
|
|
125
|
-
export function createRecentStorageKey(
|
|
126
|
-
title: string,
|
|
127
|
-
placeholder: string,
|
|
128
|
-
triggerLabel: string,
|
|
129
|
-
): string {
|
|
130
|
-
const scope =
|
|
131
|
-
[title, placeholder, triggerLabel].map((value) => value.trim().toLowerCase()).find(Boolean) ??
|
|
132
|
-
'search'
|
|
133
|
-
const normalizedScope = scope.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'search'
|
|
134
|
-
return `deepspace:search-bar:recent:${normalizedScope}`
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function readRecentSearches(storageKey: string, maxItems: number): RecentSearchEntry[] {
|
|
138
|
-
try {
|
|
139
|
-
const parsed: unknown = JSON.parse(window.localStorage.getItem(storageKey) ?? '[]')
|
|
140
|
-
if (!Array.isArray(parsed)) return []
|
|
141
|
-
return parsed.filter(isRecentSearchEntry).slice(0, normalizeLimit(maxItems))
|
|
142
|
-
} catch {
|
|
143
|
-
return []
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function rememberRecentItem(
|
|
148
|
-
storageKey: string,
|
|
149
|
-
itemId: string,
|
|
150
|
-
maxItems: number,
|
|
151
|
-
): RecentSearchEntry[] {
|
|
152
|
-
if (!itemId) return readRecentSearches(storageKey, maxItems)
|
|
153
|
-
return rememberRecent(
|
|
154
|
-
storageKey,
|
|
155
|
-
{ kind: 'item', itemId },
|
|
156
|
-
(entry) => entry.kind === 'item' && entry.itemId === itemId,
|
|
157
|
-
maxItems,
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function rememberRecentQuery(
|
|
162
|
-
storageKey: string,
|
|
163
|
-
rawQuery: string,
|
|
164
|
-
maxItems: number,
|
|
165
|
-
): RecentSearchEntry[] {
|
|
166
|
-
const query = rawQuery.trim()
|
|
167
|
-
const normalizedQuery = normalize(query)
|
|
168
|
-
if (!normalizedQuery) return readRecentSearches(storageKey, maxItems)
|
|
169
|
-
return rememberRecent(
|
|
170
|
-
storageKey,
|
|
171
|
-
{ kind: 'query', query },
|
|
172
|
-
(entry) => entry.kind === 'query' && normalize(entry.query) === normalizedQuery,
|
|
173
|
-
maxItems,
|
|
174
|
-
)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function rememberRecent(
|
|
178
|
-
storageKey: string,
|
|
179
|
-
recent: RecentSearchEntry,
|
|
180
|
-
matches: (entry: RecentSearchEntry) => boolean,
|
|
181
|
-
maxItems: number,
|
|
182
|
-
): RecentSearchEntry[] {
|
|
183
|
-
const next = [
|
|
184
|
-
recent,
|
|
185
|
-
...readRecentSearches(storageKey, maxItems).filter((entry) => !matches(entry)),
|
|
186
|
-
].slice(0, normalizeLimit(maxItems))
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
window.localStorage.setItem(storageKey, JSON.stringify(next))
|
|
190
|
-
} catch {
|
|
191
|
-
// Persistence is optional; search continues with the in-memory result.
|
|
192
|
-
}
|
|
193
|
-
return next
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function isRecentSearchEntry(value: unknown): value is RecentSearchEntry {
|
|
197
|
-
if (!value || typeof value !== 'object') return false
|
|
198
|
-
const candidate = value as Partial<RecentSearchEntry>
|
|
199
|
-
if (candidate.kind === 'item') return typeof candidate.itemId === 'string' && !!candidate.itemId
|
|
200
|
-
return (
|
|
201
|
-
candidate.kind === 'query' && typeof candidate.query === 'string' && !!candidate.query.trim()
|
|
202
|
-
)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function normalizeLimit(value: number): number {
|
|
206
|
-
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
|
|
207
|
-
}
|
|
208
|
-
|
|
209
62
|
function normalize(value: string): string {
|
|
210
63
|
return value.trim().toLowerCase()
|
|
211
64
|
}
|