feat(TRUEREF-0015): implement web UI repository dashboard
- Repository list with state badges, stats, and action buttons - Add repository modal for GitHub URLs and local paths - Live indexing progress bar polling every 2s - Confirm dialog for destructive actions - Repository detail page with versions and recent jobs - Settings page placeholder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
162
src/lib/components/AddRepositoryModal.svelte
Normal file
162
src/lib/components/AddRepositoryModal.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
onClose,
|
||||
onAdded
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onAdded: () => void;
|
||||
} = $props();
|
||||
|
||||
let source = $state<'github' | 'local'>('github');
|
||||
let sourceUrl = $state('');
|
||||
let title = $state('');
|
||||
let githubToken = $state('');
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetch('/api/v1/libs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source,
|
||||
sourceUrl,
|
||||
title: title || sourceUrl,
|
||||
githubToken: githubToken || undefined
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? 'Failed to add repository');
|
||||
}
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
role="presentation"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Add Repository</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 flex gap-2">
|
||||
<button
|
||||
class="flex-1 rounded-lg py-2 text-sm transition-colors {source === 'github'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-200 text-gray-700 hover:bg-gray-50'}"
|
||||
onclick={() => (source = 'github')}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-lg py-2 text-sm transition-colors {source === 'local'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-200 text-gray-700 hover:bg-gray-50'}"
|
||||
onclick={() => (source = 'local')}
|
||||
>
|
||||
Local Path
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
{source === 'github' ? 'GitHub URL' : 'Absolute Path'}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={sourceUrl}
|
||||
placeholder={source === 'github'
|
||||
? 'https://github.com/facebook/react'
|
||||
: '/home/user/projects/my-sdk'}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700">Display Title (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="My Library"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if source === 'github'}
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700"
|
||||
>GitHub Token <span class="font-normal text-gray-500">(optional, for private repos)</span
|
||||
></span
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={githubToken}
|
||||
placeholder="ghp_..."
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 rounded-lg bg-red-50 px-3 py-2">
|
||||
<p class="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={loading || !sourceUrl.trim()}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Adding...' : 'Add & Index'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
61
src/lib/components/ConfirmDialog.svelte
Normal file
61
src/lib/components/ConfirmDialog.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
} = $props();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
role="presentation"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl">
|
||||
<h2 class="text-base font-semibold text-gray-900">{title}</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">{message}</p>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onclick={onConfirm}
|
||||
class="rounded-lg px-4 py-2 text-sm text-white {danger
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'}"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
62
src/lib/components/IndexingProgress.svelte
Normal file
62
src/lib/components/IndexingProgress.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { IndexingJob } from '$lib/types';
|
||||
|
||||
let { jobId }: { jobId: string } = $props();
|
||||
|
||||
let job = $state<IndexingJob | null>(null);
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async function pollJob() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/jobs/${jobId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
job = data.job;
|
||||
if (job?.status === 'done' || job?.status === 'failed') {
|
||||
if (interval !== undefined) {
|
||||
clearInterval(interval);
|
||||
interval = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
pollJob();
|
||||
interval = setInterval(pollJob, 2000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval !== undefined) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
const progress = $derived(job?.progress ?? 0);
|
||||
const processedFiles = $derived(job?.processedFiles ?? 0);
|
||||
const totalFiles = $derived(job?.totalFiles ?? 0);
|
||||
</script>
|
||||
|
||||
{#if job}
|
||||
<div class="mt-2">
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>{processedFiles} / {totalFiles} files</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-1.5 rounded-full bg-blue-600 transition-all duration-300"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if job.status === 'done'}
|
||||
<p class="mt-1 text-xs text-green-600">Indexing complete.</p>
|
||||
{:else if job.status === 'failed'}
|
||||
<p class="mt-1 text-xs text-red-600">{job.error ?? 'Indexing failed.'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
89
src/lib/components/RepositoryCard.svelte
Normal file
89
src/lib/components/RepositoryCard.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import type { Repository } from '$lib/types';
|
||||
|
||||
let {
|
||||
repo,
|
||||
onReindex,
|
||||
onDelete
|
||||
}: {
|
||||
repo: Repository;
|
||||
onReindex: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
pending: 'bg-gray-100 text-gray-600',
|
||||
indexing: 'bg-blue-100 text-blue-700',
|
||||
indexed: 'bg-green-100 text-green-700',
|
||||
error: 'bg-red-100 text-red-700'
|
||||
};
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
indexing: 'Indexing...',
|
||||
indexed: 'Indexed',
|
||||
error: 'Error'
|
||||
};
|
||||
|
||||
const totalSnippets = $derived(repo.totalSnippets ?? 0);
|
||||
const trustScore = $derived(repo.trustScore ?? 0);
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate font-semibold text-gray-900">{repo.title}</h3>
|
||||
<p class="mt-0.5 truncate font-mono text-sm text-gray-500">{repo.id}</p>
|
||||
</div>
|
||||
<span
|
||||
class="ml-3 shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {stateColors[repo.state] ??
|
||||
'bg-gray-100 text-gray-600'}"
|
||||
>
|
||||
{stateLabels[repo.state] ?? repo.state}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if repo.description}
|
||||
<p class="mt-2 line-clamp-2 text-sm text-gray-600">{repo.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-500">
|
||||
<span>{totalSnippets.toLocaleString()} snippets</span>
|
||||
<span>·</span>
|
||||
<span>Trust: {trustScore.toFixed(1)}/10</span>
|
||||
{#if repo.stars}
|
||||
<span>·</span>
|
||||
<span>★ {repo.stars.toLocaleString()}</span>
|
||||
{/if}
|
||||
{#if repo.lastIndexedAt}
|
||||
<span>·</span>
|
||||
<span>Last indexed {new Date(repo.lastIndexedAt).toLocaleDateString()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if repo.state === 'error'}
|
||||
<p class="mt-2 text-xs text-red-600">Indexing failed. Check jobs for details.</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
onclick={() => onReindex(repo.id)}
|
||||
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={repo.state === 'indexing'}
|
||||
>
|
||||
{repo.state === 'indexing' ? 'Indexing...' : 'Re-index'}
|
||||
</button>
|
||||
<a
|
||||
href="/repos/{encodeURIComponent(repo.id)}"
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
<button
|
||||
onclick={() => onDelete(repo.id)}
|
||||
class="ml-auto rounded-lg px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
23
src/lib/components/StatBadge.svelte
Normal file
23
src/lib/components/StatBadge.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
label,
|
||||
value,
|
||||
variant = 'default'
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger';
|
||||
} = $props();
|
||||
|
||||
const variantClasses: Record<string, string> = {
|
||||
default: 'bg-gray-100 text-gray-700',
|
||||
success: 'bg-green-100 text-green-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
danger: 'bg-red-100 text-red-700'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center rounded-lg p-3 {variantClasses[variant] ?? variantClasses.default}">
|
||||
<span class="text-lg font-bold">{value}</span>
|
||||
<span class="mt-0.5 text-xs">{label}</span>
|
||||
</div>
|
||||
Reference in New Issue
Block a user