mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(shared-ui): add content search support to GlobalSpotlight
Add ContentSearcher interface, debounced cross-app search with AbortController, loading indicators, and content result rendering to the spotlight component. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d51ee49967
commit
f797d70a9e
4 changed files with 197 additions and 6 deletions
|
|
@ -121,6 +121,9 @@ export type {
|
|||
ExpandableToolbarProps,
|
||||
RecentAppEntry,
|
||||
SpotlightAction,
|
||||
ContentSearcher,
|
||||
ContentSearchResult,
|
||||
ContentSearchGroup,
|
||||
} from './navigation';
|
||||
|
||||
// Settings
|
||||
|
|
|
|||
|
|
@ -12,12 +12,40 @@
|
|||
onExecute: () => void;
|
||||
}
|
||||
|
||||
export interface ContentSearchResult {
|
||||
id: string;
|
||||
type: string;
|
||||
appId: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
appIcon?: string;
|
||||
appColor?: string;
|
||||
href: string;
|
||||
score: number;
|
||||
matchedField?: string;
|
||||
}
|
||||
|
||||
export interface ContentSearchGroup {
|
||||
appId: string;
|
||||
appName: string;
|
||||
appIcon?: string;
|
||||
appColor?: string;
|
||||
results: ContentSearchResult[];
|
||||
}
|
||||
|
||||
export type ContentSearcher = (
|
||||
query: string,
|
||||
signal: AbortSignal
|
||||
) => Promise<ContentSearchGroup[]>;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
apps: PillAppItem[];
|
||||
quickActions?: SpotlightAction[];
|
||||
placeholder?: string;
|
||||
/** Content searcher for cross-app IndexedDB search */
|
||||
contentSearcher?: ContentSearcher;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -26,6 +54,7 @@
|
|||
apps,
|
||||
quickActions = [],
|
||||
placeholder = 'Was möchtest du tun?',
|
||||
contentSearcher,
|
||||
}: Props = $props();
|
||||
|
||||
const store = createAppNavigationStore();
|
||||
|
|
@ -33,10 +62,14 @@
|
|||
let searchQuery = $state('');
|
||||
let selectedIndex = $state(0);
|
||||
let inputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||
let contentResults = $state<ContentSearchGroup[]>([]);
|
||||
let contentLoading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortController: AbortController | undefined;
|
||||
|
||||
// Build combined results list
|
||||
// Build combined results list (apps + actions)
|
||||
interface SpotlightResult {
|
||||
type: 'app' | 'action';
|
||||
type: 'app' | 'action' | 'content';
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
|
|
@ -44,6 +77,8 @@
|
|||
imageUrl?: string;
|
||||
shortcut?: string;
|
||||
category?: string;
|
||||
href?: string;
|
||||
appColor?: string;
|
||||
}
|
||||
|
||||
const results = $derived.by(() => {
|
||||
|
|
@ -106,11 +141,58 @@
|
|||
category: action.category || 'Aktionen',
|
||||
});
|
||||
}
|
||||
|
||||
// Append content search results
|
||||
for (const group of contentResults) {
|
||||
for (const result of group.results) {
|
||||
items.push({
|
||||
type: 'content',
|
||||
id: result.id,
|
||||
label: result.title,
|
||||
description: result.subtitle,
|
||||
imageUrl: group.appIcon,
|
||||
category: group.appName,
|
||||
href: result.href,
|
||||
appColor: group.appColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Trigger content search on query change
|
||||
$effect(() => {
|
||||
const q = searchQuery.trim();
|
||||
if (!q || !contentSearcher) {
|
||||
contentResults = [];
|
||||
contentLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contentLoading = true;
|
||||
clearTimeout(debounceTimer);
|
||||
abortController?.abort();
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
try {
|
||||
const groups = await contentSearcher!(q, signal);
|
||||
if (!signal.aborted) {
|
||||
contentResults = groups;
|
||||
contentLoading = false;
|
||||
}
|
||||
} catch {
|
||||
if (!signal.aborted) {
|
||||
contentResults = [];
|
||||
contentLoading = false;
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Group results by category for display
|
||||
const groupedResults = $derived.by(() => {
|
||||
const groups: { category: string; items: SpotlightResult[] }[] = [];
|
||||
|
|
@ -140,11 +222,19 @@
|
|||
if (open) {
|
||||
searchQuery = '';
|
||||
selectedIndex = 0;
|
||||
contentResults = [];
|
||||
contentLoading = false;
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
function handleSelect(item: SpotlightResult) {
|
||||
if (item.type === 'content' && item.href) {
|
||||
window.location.href = item.href;
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'app') {
|
||||
const app = apps.find((a) => a.id === item.id);
|
||||
if (app) {
|
||||
|
|
@ -258,6 +348,8 @@
|
|||
d={iconPaths[item.icon]}
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.type === 'content' && item.appColor}
|
||||
<span class="spotlight-content-dot" style:background={item.appColor}></span>
|
||||
{:else}
|
||||
<span class="spotlight-item-dot">
|
||||
{item.type === 'app' ? '\u{1F4F1}' : '\u{26A1}'}
|
||||
|
|
@ -272,13 +364,40 @@
|
|||
</div>
|
||||
{#if item.shortcut}
|
||||
<kbd class="spotlight-shortcut">{item.shortcut}</kbd>
|
||||
{:else if item.type === 'content'}
|
||||
<svg
|
||||
class="spotlight-item-arrow"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={iconPaths.arrow}
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if contentLoading}
|
||||
<div class="spotlight-loading">
|
||||
<span class="spotlight-loading-dot"></span>
|
||||
<span class="spotlight-loading-dot"></span>
|
||||
<span class="spotlight-loading-dot"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if searchQuery}
|
||||
{:else if searchQuery && !contentLoading}
|
||||
<div class="spotlight-empty">Keine Ergebnisse</div>
|
||||
{:else if searchQuery && contentLoading}
|
||||
<div class="spotlight-loading-full">
|
||||
<span class="spotlight-loading-dot"></span>
|
||||
<span class="spotlight-loading-dot"></span>
|
||||
<span class="spotlight-loading-dot"></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer hints -->
|
||||
|
|
@ -402,7 +521,7 @@
|
|||
|
||||
/* Results */
|
||||
.spotlight-results {
|
||||
max-height: 360px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
|
@ -549,6 +668,63 @@
|
|||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Content search dot (colored by app) */
|
||||
.spotlight-content-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.spotlight-item-arrow {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
opacity: 0.3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.spotlight-loading,
|
||||
.spotlight-loading-full {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.spotlight-loading-full {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spotlight-loading-dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
animation: spotlightPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.spotlight-loading-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.spotlight-loading-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes spotlightPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 640px) {
|
||||
.spotlight-overlay {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@
|
|||
import PillTabGroup from './PillTabGroup.svelte';
|
||||
import PillTagSelector from './PillTagSelector.svelte';
|
||||
import AppDrawer from './AppDrawer.svelte';
|
||||
import GlobalSpotlight, { type SpotlightAction } from './GlobalSpotlight.svelte';
|
||||
import GlobalSpotlight, {
|
||||
type SpotlightAction,
|
||||
type ContentSearcher,
|
||||
} from './GlobalSpotlight.svelte';
|
||||
import { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
|
||||
// Phosphor Icons (via shared-icons)
|
||||
import {
|
||||
|
|
@ -290,6 +293,8 @@
|
|||
spotlightActions?: SpotlightAction[];
|
||||
/** Placeholder text for spotlight search */
|
||||
spotlightPlaceholder?: string;
|
||||
/** Content searcher for cross-app search in spotlight */
|
||||
contentSearcher?: ContentSearcher;
|
||||
/** Accessible label for the nav element */
|
||||
ariaLabel?: string;
|
||||
/** Feedback page href (shown in user dropdown). Set to empty string to hide. */
|
||||
|
|
@ -345,6 +350,7 @@
|
|||
onOpenInPanel,
|
||||
spotlightActions,
|
||||
spotlightPlaceholder,
|
||||
contentSearcher,
|
||||
ariaLabel,
|
||||
feedbackHref = '/feedback',
|
||||
themesHref,
|
||||
|
|
@ -804,6 +810,7 @@
|
|||
apps={appItems}
|
||||
quickActions={spotlightActions}
|
||||
placeholder={spotlightPlaceholder}
|
||||
{contentSearcher}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ export { default as PillNavigation } from './PillNavigation.svelte';
|
|||
export { default as PillDropdown } from './PillDropdown.svelte';
|
||||
export { default as AppDrawer } from './AppDrawer.svelte';
|
||||
export { default as GlobalSpotlight } from './GlobalSpotlight.svelte';
|
||||
export type { SpotlightAction } from './GlobalSpotlight.svelte';
|
||||
export type {
|
||||
SpotlightAction,
|
||||
ContentSearcher,
|
||||
ContentSearchResult,
|
||||
ContentSearchGroup,
|
||||
} from './GlobalSpotlight.svelte';
|
||||
export { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
|
||||
export {
|
||||
createAppNavigationStore,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue