feat(TRUEREF-0016): implement web UI search explorer
- Two-step search workflow: resolve library then query documentation - URL state sync (/search?lib=...&q=...) - LibraryResult, SnippetCard, SearchInput components - Code/info snippet display with breadcrumbs and token counts - Copy-as-markdown button for full response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
src/lib/components/search/LibraryResult.svelte
Normal file
33
src/lib/components/search/LibraryResult.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
result,
|
||||
onSelect
|
||||
}: {
|
||||
result: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
totalSnippets: number | null;
|
||||
trustScore: number | null;
|
||||
};
|
||||
onSelect: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const snippetCount = $derived(result.totalSnippets ?? 0);
|
||||
const trust = $derived(result.trustScore ?? 0);
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={() => onSelect(result.id)}
|
||||
class="w-full rounded-xl border border-gray-200 bg-white p-4 text-left shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-gray-900">{result.title}</span>
|
||||
<span class="text-xs text-gray-400">Trust {trust.toFixed(1)}/10</span>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-400">{result.id}</p>
|
||||
{#if result.description}
|
||||
<p class="mt-1.5 line-clamp-2 text-sm text-gray-600">{result.description}</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-gray-400">{snippetCount.toLocaleString()} snippets</p>
|
||||
</button>
|
||||
56
src/lib/components/search/SearchInput.svelte
Normal file
56
src/lib/components/search/SearchInput.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = 'Search...',
|
||||
onsubmit,
|
||||
loading = false
|
||||
}: {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onsubmit: () => void;
|
||||
loading?: boolean;
|
||||
} = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
onsubmit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value
|
||||
{placeholder}
|
||||
onkeydown={handleKeydown}
|
||||
disabled={loading}
|
||||
class="flex-1 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm outline-none transition-all focus:border-blue-400 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
onclick={onsubmit}
|
||||
disabled={loading || !value.trim()}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Searching
|
||||
</span>
|
||||
{:else}
|
||||
Search
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
55
src/lib/components/search/SnippetCard.svelte
Normal file
55
src/lib/components/search/SnippetCard.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { SnippetJson } from '$lib/server/api/formatters';
|
||||
|
||||
let { snippet }: { snippet: SnippetJson } = $props();
|
||||
|
||||
const isCode = $derived(snippet.type === 'code');
|
||||
|
||||
const title = $derived(
|
||||
snippet.type === 'code' ? snippet.title : null
|
||||
);
|
||||
|
||||
const breadcrumb = $derived(
|
||||
snippet.type === 'code' ? snippet.description : snippet.breadcrumb
|
||||
);
|
||||
|
||||
const content = $derived(
|
||||
snippet.type === 'code' ? snippet.codeList[0]?.code ?? '' : snippet.text
|
||||
);
|
||||
|
||||
const language = $derived(
|
||||
snippet.type === 'code' ? (snippet.codeList[0]?.language ?? '') : null
|
||||
);
|
||||
|
||||
const tokenCount = $derived(snippet.tokenCount ?? 0);
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isCode}
|
||||
<span class="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">code</span>
|
||||
{:else}
|
||||
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">info</span>
|
||||
{/if}
|
||||
{#if title}
|
||||
<span class="text-sm font-medium text-gray-800">{title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{tokenCount} tokens</span>
|
||||
</div>
|
||||
|
||||
{#if breadcrumb}
|
||||
<p class="bg-gray-50 px-4 py-1.5 text-xs italic text-gray-500">{breadcrumb}</p>
|
||||
{/if}
|
||||
|
||||
<div class="p-4">
|
||||
{#if isCode}
|
||||
<pre
|
||||
class="overflow-x-auto rounded bg-gray-950 p-4 text-sm text-gray-100"
|
||||
><code>{content}</code></pre>
|
||||
{:else}
|
||||
<div class="prose prose-sm max-w-none whitespace-pre-wrap text-gray-700">{content}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
36
src/lib/utils/copy-to-clipboard.ts
Normal file
36
src/lib/utils/copy-to-clipboard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copy text to the system clipboard.
|
||||
*
|
||||
* Falls back gracefully when the Clipboard API is unavailable (e.g. non-HTTPS
|
||||
* or older browsers) by using a temporary textarea element.
|
||||
*
|
||||
* @returns true when the copy succeeded, false otherwise.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to legacy path.
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback via execCommand.
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user