feat: migrate from RapidAPI to TheSportsDB with SvelteKit dashboard
- Replace free-api-live-football-data (RapidAPI) backend with TheSportsDB - Add PostgreSQL cache layer for permanent data (teams, players, leagues, events) - Replace Bootstrap dashboard with SvelteKit-based interactive dashboard - Restructure MCP tools around TheSportsDB capabilities (get_team_info, get_roster, get_fixtures, get_standings, etc.) - Expose tool registry via GET /api/tools so dashboard stays in sync - Remove legacy modules and references (api_football, sync, RapidAPI env vars)
This commit is contained in:
@@ -1,98 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { createTooltip, melt } from '@melt-ui/svelte';
|
||||
import { runTool } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchTools, runTool } from '$lib/api';
|
||||
import type { ToolInfo, ToolParam } from '$lib/types';
|
||||
|
||||
// ── Tool definitions ────────────────────────────────────
|
||||
|
||||
type ParamType = 'text' | 'number' | 'date' | 'select';
|
||||
|
||||
interface Param {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ParamType;
|
||||
default: string;
|
||||
// ── Frontend-only form niceties, keyed by backend param name ──
|
||||
// The backend tool schema carries name/type/default/required; labels,
|
||||
// placeholders, select options, and the input widget live here so the
|
||||
// API stays presentation-free.
|
||||
interface ParamUi {
|
||||
label?: string;
|
||||
input?: 'date' | 'select';
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
fallbackDefault?: string;
|
||||
}
|
||||
const PARAM_UI: Record<string, ParamUi> = {
|
||||
team_name: { label: 'Team Name', placeholder: 'e.g. Arsenal, Toronto FC', fallbackDefault: 'Toronto FC' },
|
||||
player_name: { label: 'Player Name', placeholder: 'e.g. Federico Bernardeschi' },
|
||||
status: { label: 'Status Filter', input: 'select', options: ['all', 'upcoming', 'past'] },
|
||||
match_date: { label: 'Date', input: 'date' },
|
||||
event_id: { label: 'Event ID', placeholder: 'Get from get_fixtures first' },
|
||||
league: { label: 'League', placeholder: 'e.g. English Premier League' },
|
||||
season: { label: 'Season', placeholder: 'e.g. 2026 or 2025-2026' },
|
||||
};
|
||||
|
||||
interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
premium: boolean;
|
||||
params: Param[];
|
||||
function uiFor(p: ToolParam): ParamUi {
|
||||
return PARAM_UI[p.name] ?? {};
|
||||
}
|
||||
function labelFor(p: ToolParam) {
|
||||
return uiFor(p).label ?? p.name;
|
||||
}
|
||||
function inputType(p: ToolParam): 'text' | 'number' | 'date' {
|
||||
const ui = uiFor(p);
|
||||
if (ui.input === 'date') return 'date';
|
||||
return p.type === 'integer' ? 'number' : 'text';
|
||||
}
|
||||
function initialValue(p: ToolParam): string {
|
||||
if (p.default != null) return String(p.default);
|
||||
return uiFor(p).fallbackDefault ?? '';
|
||||
}
|
||||
|
||||
const TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: 'get_team_info',
|
||||
description: 'Team profile: stadium, capacity, location, founded year, colors.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC', placeholder: 'e.g. Arsenal, Toronto FC' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_roster',
|
||||
description: 'Current squad grouped by position. Requires premium key for live data.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_player_info',
|
||||
description: 'Player profile: position, nationality, DOB, team, status.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'player_name', label: 'Player Name', type: 'text', default: '', placeholder: 'e.g. Federico Bernardeschi' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_fixtures',
|
||||
description: 'Recent results and upcoming matches for a team.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
|
||||
{ key: 'status', label: 'Status Filter', type: 'select', default: 'all', options: ['all', 'upcoming', 'past'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_standings',
|
||||
description: 'Full league table with points, goal difference, and form.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'league', label: 'League', type: 'text', default: 'American Major League Soccer', placeholder: 'e.g. English Premier League' },
|
||||
{ key: 'season', label: 'Season', type: 'text', default: '2026', placeholder: 'e.g. 2026 or 2025-2026' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_match_result',
|
||||
description: 'Match result for a team on a specific date.',
|
||||
premium: false,
|
||||
params: [
|
||||
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
|
||||
{ key: 'match_date', label: 'Date', type: 'date', default: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_match_detail',
|
||||
description: 'Deep match stats, lineup, and timeline. Requires a premium TheSportsDB key.',
|
||||
premium: true,
|
||||
params: [
|
||||
{ key: 'event_id', label: 'Event ID', type: 'number', default: '', placeholder: 'Get from get_fixtures first' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_livescores',
|
||||
description: 'Current live soccer scores worldwide. Requires a premium TheSportsDB key.',
|
||||
premium: true,
|
||||
params: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── State ────────────────────────────────────────────────
|
||||
|
||||
let selectedTool = $state<ToolDef>(TOOLS[0]);
|
||||
let tools = $state<ToolInfo[]>([]);
|
||||
let selectedTool = $state<ToolInfo | null>(null);
|
||||
let paramValues = $state<Record<string, string>>({});
|
||||
let running = $state(false);
|
||||
let result = $state<string | null>(null);
|
||||
@@ -107,26 +58,30 @@
|
||||
}
|
||||
let history = $state<HistoryEntry[]>([]);
|
||||
|
||||
function selectTool(tool: ToolDef) {
|
||||
function selectTool(tool: ToolInfo) {
|
||||
selectedTool = tool;
|
||||
result = null;
|
||||
resultError = null;
|
||||
paramValues = Object.fromEntries(tool.params.map((p) => [p.key, p.default]));
|
||||
paramValues = Object.fromEntries(tool.params.map((p) => [p.name, initialValue(p)]));
|
||||
}
|
||||
|
||||
// Init
|
||||
selectTool(TOOLS[0]);
|
||||
onMount(async () => {
|
||||
const r = await fetchTools();
|
||||
tools = r.tools;
|
||||
if (tools.length > 0) selectTool(tools[0]);
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!selectedTool) return;
|
||||
running = true;
|
||||
result = null;
|
||||
resultError = null;
|
||||
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const p of selectedTool.params) {
|
||||
const val = paramValues[p.key];
|
||||
const val = paramValues[p.name];
|
||||
if (val === '') continue;
|
||||
args[p.key] = p.type === 'number' ? Number(val) : val;
|
||||
args[p.name] = p.type === 'integer' ? Number(val) : val;
|
||||
}
|
||||
|
||||
const snapshot = { ...paramValues };
|
||||
@@ -149,7 +104,7 @@
|
||||
}
|
||||
|
||||
function loadHistory(entry: HistoryEntry) {
|
||||
const tool = TOOLS.find((t) => t.name === entry.tool);
|
||||
const tool = tools.find((t) => t.name === entry.tool);
|
||||
if (!tool) return;
|
||||
selectTool(tool);
|
||||
// selectTool resets paramValues, restore after microtask
|
||||
@@ -168,190 +123,167 @@
|
||||
function fmtTime(d: Date) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
// Melt UI tooltip for premium badge
|
||||
const {
|
||||
elements: { trigger: premTrigger, content: premContent },
|
||||
states: { open: premOpen },
|
||||
} = createTooltip({ positioning: { placement: 'top' }, openDelay: 200 });
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Tool Runner</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<h1 class="text-lg font-semibold">Tool Runner</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Run MCP tools interactively and inspect raw API responses. Useful for spotting strange API
|
||||
results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
|
||||
<!-- Left: selector + form + result -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<!-- Tool selector -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
|
||||
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Select Tool</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each TOOLS as tool}
|
||||
<button
|
||||
onclick={() => selectTool(tool)}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium transition-colors
|
||||
{selectedTool.name === tool.name
|
||||
? 'bg-green-700 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700'}"
|
||||
>
|
||||
{tool.name}
|
||||
{#if tool.premium}
|
||||
<span use:melt={$premTrigger} class="ml-1 text-amber-500 dark:text-amber-400 cursor-help">★</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameter form -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white font-mono">{selectedTool.name}</h2>
|
||||
{#if selectedTool.premium}
|
||||
<span
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/50"
|
||||
>
|
||||
Premium
|
||||
</span>
|
||||
{/if}
|
||||
{#if !selectedTool}
|
||||
<div class="flex items-center gap-2 text-base-content/60 text-sm">
|
||||
<span class="loading loading-spinner loading-sm"></span> Loading tools…
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
|
||||
<!-- Left: selector + form + result -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<!-- Tool selector -->
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider mb-1">Select Tool</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each tools as tool}
|
||||
<button
|
||||
onclick={() => selectTool(tool)}
|
||||
class="btn btn-sm {selectedTool.name === tool.name ? 'btn-primary' : 'btn-soft'}"
|
||||
>
|
||||
{tool.name}
|
||||
{#if tool.premium}
|
||||
<span
|
||||
class="tooltip cursor-help text-warning"
|
||||
data-tip="Requires a premium TheSportsDB key"
|
||||
>
|
||||
★
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{selectedTool.description}</p>
|
||||
</div>
|
||||
|
||||
{#if selectedTool.params.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each selectedTool.params as param}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5" for={param.key}>
|
||||
{param.label}
|
||||
</label>
|
||||
{#if param.type === 'select'}
|
||||
<select
|
||||
id={param.key}
|
||||
bind:value={paramValues[param.key]}
|
||||
class="w-full rounded-md bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm
|
||||
text-gray-900 dark:text-gray-100 focus:outline-none focus:border-green-600 focus:ring-1
|
||||
focus:ring-green-600"
|
||||
>
|
||||
{#each param.options ?? [] as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
id={param.key}
|
||||
type={param.type === 'number' ? 'number' : param.type === 'date' ? 'date' : 'text'}
|
||||
placeholder={param.placeholder ?? ''}
|
||||
bind:value={paramValues[param.key]}
|
||||
class="w-full rounded-md bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm
|
||||
text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-green-600
|
||||
focus:ring-1 focus:ring-green-600"
|
||||
/>
|
||||
<!-- Parameter form -->
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-sm font-semibold font-mono">{selectedTool.name}</h2>
|
||||
{#if selectedTool.premium}
|
||||
<span class="badge badge-warning badge-sm">Premium</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-500 italic">No parameters — just click Run.</p>
|
||||
{/if}
|
||||
<p class="text-xs text-base-content/60 mt-1 whitespace-pre-line">{selectedTool.description}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={submit}
|
||||
disabled={running}
|
||||
class="px-4 py-2 rounded-md bg-green-700 hover:bg-green-600 text-white text-sm
|
||||
font-medium disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{#if running}
|
||||
<span class="inline-block w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
|
||||
Running…
|
||||
{:else}
|
||||
Run Tool
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
{#if result !== null || resultError !== null}
|
||||
<div
|
||||
class="rounded-lg bg-white dark:bg-gray-900 border {resultError
|
||||
? 'border-red-200 dark:border-red-800'
|
||||
: 'border-gray-200 dark:border-gray-800'}"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-2.5 border-b {resultError
|
||||
? 'border-red-200 dark:border-red-800'
|
||||
: 'border-gray-200 dark:border-gray-800'} flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs font-medium {resultError ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
|
||||
{resultError ? 'Error' : 'Result'}
|
||||
</span>
|
||||
{#if result}
|
||||
<span class="text-xs text-gray-500">
|
||||
{result.split('\n').length} lines
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<pre
|
||||
class="px-4 py-4 text-sm font-mono whitespace-pre-wrap text-gray-700 dark:text-gray-200 overflow-x-auto
|
||||
max-h-[520px] overflow-y-auto leading-relaxed"
|
||||
>{resultError ?? result}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: session history -->
|
||||
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 sticky top-20">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider">Session History</h2>
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="px-4 py-5 text-sm text-gray-500">No queries yet.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800 max-h-[600px] overflow-y-auto">
|
||||
{#each history as entry}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => loadHistory(entry)}
|
||||
class="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<code class="text-xs {entry.ok ? 'text-green-600 dark:text-green-300' : 'text-red-500 dark:text-red-400'} truncate">
|
||||
{entry.tool}
|
||||
</code>
|
||||
<span class="text-xs text-gray-500 shrink-0">{fmtTime(entry.ts)}</span>
|
||||
</div>
|
||||
{#each Object.entries(entry.args) as [k, v]}
|
||||
{#if v}
|
||||
<div class="text-xs text-gray-500 truncate mt-0.5">
|
||||
{k}: <span class="text-gray-500 dark:text-gray-400">{v}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedTool.params.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each selectedTool.params as param}
|
||||
<div>
|
||||
<label class="label text-xs font-medium mb-1" for={param.name}>
|
||||
{labelFor(param)}
|
||||
</label>
|
||||
{#if uiFor(param).input === 'select'}
|
||||
<select
|
||||
id={param.name}
|
||||
bind:value={paramValues[param.name]}
|
||||
class="select select-sm w-full"
|
||||
>
|
||||
{#each uiFor(param).options ?? [] as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
id={param.name}
|
||||
type={inputType(param)}
|
||||
placeholder={uiFor(param).placeholder ?? ''}
|
||||
bind:value={paramValues[param.name]}
|
||||
class="input input-sm w-full"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if !entry.ok}
|
||||
<div class="text-xs text-red-500 mt-0.5 truncate">{entry.output}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-base-content/60 italic">No parameters — just click Run.</p>
|
||||
{/if}
|
||||
|
||||
<!-- Melt UI tooltip for premium star -->
|
||||
{#if $premOpen}
|
||||
<div
|
||||
use:melt={$premContent}
|
||||
class="z-50 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2.5 py-1.5 text-xs text-amber-700 dark:text-amber-300 shadow-lg"
|
||||
>
|
||||
Requires a premium TheSportsDB key
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={submit} disabled={running} class="btn btn-primary btn-sm self-start">
|
||||
{#if running}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Running…
|
||||
{:else}
|
||||
Run Tool
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
{#if result !== null || resultError !== null}
|
||||
<div class="card bg-base-100 border {resultError ? 'border-error' : 'border-base-300'}">
|
||||
<div
|
||||
class="px-4 py-2.5 border-b {resultError ? 'border-error' : 'border-base-300'} flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs font-medium {resultError ? 'text-error' : 'text-success'}">
|
||||
{resultError ? 'Error' : 'Result'}
|
||||
</span>
|
||||
{#if result}
|
||||
<span class="text-xs text-base-content/60">
|
||||
{result.split('\n').length} lines
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<pre
|
||||
class="px-4 py-4 text-sm font-mono whitespace-pre-wrap overflow-x-auto
|
||||
max-h-[520px] overflow-y-auto leading-relaxed"
|
||||
>{resultError ?? result}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: session history -->
|
||||
<div class="card bg-base-100 border border-base-300 sticky top-20">
|
||||
<div class="px-4 py-3 border-b border-base-300">
|
||||
<h2 class="text-xs font-medium text-base-content/60 uppercase tracking-wider">Session History</h2>
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="px-4 py-5 text-sm text-base-content/60">No queries yet.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-base-300 max-h-[600px] overflow-y-auto">
|
||||
{#each history as entry}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => loadHistory(entry)}
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<code class="text-xs {entry.ok ? 'text-primary' : 'text-error'} truncate">
|
||||
{entry.tool}
|
||||
</code>
|
||||
<span class="text-xs text-base-content/60 shrink-0">{fmtTime(entry.ts)}</span>
|
||||
</div>
|
||||
{#each Object.entries(entry.args) as [k, v]}
|
||||
{#if v}
|
||||
<div class="text-xs text-base-content/60 truncate mt-0.5">
|
||||
{k}: <span class="text-base-content/80">{v}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if !entry.ok}
|
||||
<div class="text-xs text-error mt-0.5 truncate">{entry.output}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user