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:
Giancarmine Salucci
2026-03-23 09:07:13 +01:00
parent 90d93786a8
commit 22bf4c1014
6 changed files with 473 additions and 0 deletions

View 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>

View 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>

View 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>

View 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;
}
}