This commit is contained in:
Hadi
2026-04-06 15:12:34 +02:00
commit 4989225671
117 changed files with 11454 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
<script lang="ts">
interface Sheet {
id: string;
title: string;
description?: string;
tags?: string[];
}
let { sheets }: { sheets: Sheet[] } = $props();
let search = $state("");
let activeTag: string | null = $state(
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("tag")
: null
);
const allTags = [...new Set(sheets.flatMap((s) => s.tags ?? []))].sort();
const filtered = $derived(
sheets.filter((s) => {
const q = search.toLowerCase();
const matchSearch =
!q ||
s.title.toLowerCase().includes(q) ||
(s.description?.toLowerCase().includes(q) ?? false);
const matchTag = !activeTag || (s.tags?.includes(activeTag) ?? false);
return matchSearch && matchTag;
})
);
</script>
<div class="flex flex-col gap-4">
<input
type="search"
placeholder="Search cheatsheets..."
bind:value={search}
class="input input-bordered w-full"
/>
{#if allTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each allTags as tag}
<button
class="badge badge-md cursor-pointer transition-colors {activeTag === tag
? 'badge-primary'
: 'badge-ghost hover:badge-outline'}"
onclick={() => (activeTag = activeTag === tag ? null : tag)}
>
{tag}
</button>
{/each}
</div>
{/if}
<div class="flex flex-col gap-3">
{#if filtered.length === 0}
<p class="text-base-content/40 text-sm py-6 text-center">No results.</p>
{:else}
{#each filtered as sheet}
<a
href={`/cheatsheets/${sheet.id}`}
class="card bg-base-200 hover:bg-base-300 transition-colors p-4 flex flex-row items-center gap-4"
>
<div class="size-2 rounded-full bg-primary shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm">{sheet.title}</div>
{#if sheet.description}
<div class="text-base-content/50 text-xs mt-0.5">{sheet.description}</div>
{/if}
</div>
{#if sheet.tags && sheet.tags.length > 0}
<div class="flex gap-1 shrink-0">
{#each sheet.tags as tag}
<span class="badge badge-xs badge-ghost">{tag}</span>
{/each}
</div>
{/if}
</a>
{/each}
{/if}
</div>
</div>

View File

@@ -0,0 +1,24 @@
<script>
import { FlaskConical } from "@lucide/svelte";
let demo = $state(false);
async function checkDemo() {
try {
const res = await fetch("/api/config");
if (res.ok) {
const data = await res.json();
demo = data.demo === true;
}
} catch (_) {}
}
checkDemo();
</script>
{#if demo}
<div class="w-full bg-warning/15 border-b border-warning/30 py-1.5 px-4 flex items-center justify-center gap-2 text-xs text-warning">
<FlaskConical size={13} class="shrink-0" />
<span>Demo mode — searches and configuration changes are disabled</span>
</div>
{/if}

View File

@@ -0,0 +1,98 @@
<script>
import { RotateCw, AlertTriangle } from "@lucide/svelte";
import SearchBar from "./SearchBar.svelte";
import SearchList from "./SearchList.svelte";
import { onMount } from "svelte";
let searches = $state([]);
let loadError = $state("");
let redirecting = $state(false);
let redirectTarget = $state("");
let demo = $state(false);
onMount(async () => {
loadSearches();
fetch("/api/config")
.then((r) => r.ok ? r.json() : null)
.then((d) => { if (d) demo = d.demo === true; })
.catch(() => {});
const params = new URLSearchParams(window.location.search);
const target = params.get("target");
const type = params.get("type");
if (target && type) {
// Clean URL before launching so a refresh doesn't re-trigger
window.history.replaceState({}, "", window.location.pathname);
await handleSearch(target, type, params.get("profile") || "default");
}
});
async function loadSearches() {
try {
const res = await fetch("/api/searches");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
searches = (data ?? []).sort(
(a, b) => new Date(b.started_at) - new Date(a.started_at)
);
} catch (e) {
loadError = e.message;
}
}
async function handleSearch(target, inputType, profile) {
redirectTarget = target;
redirecting = true;
try {
const res = await fetch("/api/searches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target, input_type: inputType, profile: profile || undefined }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
const s = await res.json();
window.location.href = `/search/${s.id}`;
} catch (e) {
redirecting = false;
throw e;
}
}
async function handleDelete(id) {
await fetch(`/api/searches/${id}`, { method: "DELETE" });
searches = searches.filter((s) => s.id !== id);
}
</script>
<div class="flex flex-col gap-8">
<div class="card bg-base-200 shadow p-6">
{#if redirecting}
<div class="flex flex-col items-center justify-center gap-3 py-4">
<span class="loading loading-dots loading-md text-primary"></span>
<p class="text-sm text-base-content/60">
Searching <span class="font-mono text-base-content/90">{redirectTarget}</span>...
</p>
</div>
{:else}
<SearchBar onSearch={handleSearch} {demo} />
{/if}
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Recent searches</h2>
<button class="btn btn-ghost btn-xs" onclick={loadSearches}><RotateCw class="size-3" /> refresh</button>
</div>
{#if loadError}
<div class="alert alert-error text-sm gap-2"><AlertTriangle size={15} class="shrink-0" />{loadError}</div>
{:else}
<SearchList {searches} onDelete={handleDelete} />
{/if}
</div>
</div>

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import {
Menu,
Search,
Hammer,
SlidersHorizontal,
GitBranch,
User,
BookOpen,
Bug,
ClipboardList,
} from "@lucide/svelte";
import type { Snippet } from "svelte";
let {
action,
}: {
title?: string;
action?: Snippet;
} = $props();
const navLinks = [
{ label: "Search", href: "/", icon: Search },
{ label: "Tools", href: "/tools", icon: Hammer },
{ label: "Profiles", href: "/profiles", icon: SlidersHorizontal },
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
{
label: "More",
children: [
{ label: "How it works", href: "/help", icon: BookOpen },
{
label: "Source code",
href: "https://github.com/anotherhadi/iknowyou",
icon: GitBranch,
},
{
label: "Report a Bug",
href: "https://github.com/anotherhadi/iknowyou/issues",
icon: Bug,
},
{ label: "About me", href: "https://hadi.icu", icon: User },
],
},
];
</script>
<div class="bg-base-200">
<div class="navbar max-w-5xl m-auto">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<Menu size={20} />
</div>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content bg-base-300 rounded-box z-50 mt-3 w-52 p-2"
>
{#each navLinks as link}
<li>
{#if link.children}
<span>{link.label}</span>
<ul class="p-2">
{#each link.children as sublink}
<li>
<a href={sublink.href} class="flex items-center gap-2">
{#if sublink.icon}
{@const Icon = sublink.icon}
<Icon size={12} />
{/if}
{sublink.label}
</a>
</li>
{/each}
</ul>
{:else}
<a href={link.href} class="flex items-center gap-2">
{#if link.icon}
{@const Icon = link.icon}
<Icon size={12} />
{/if}
{link.label}
</a>
{/if}
</li>
{/each}
</ul>
</div>
<a
href="/"
class="btn btn-ghost text-xl flex justify-center gap-2 items-center"
>
<img src="/logo.svg" class="m-auto h-6" alt="iky logo" />
<img src="/logo-large.svg" class="m-auto h-6" alt="iky logo large" />
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
{#each navLinks as link}
<li>
{#if link.children}
<details>
<summary>{link.label}</summary>
<ul class="p-2 bg-base-300 w-52 z-50">
{#each link.children as sublink}
<li>
<a href={sublink.href} class="flex items-center gap-2">
{#if sublink.icon}
{@const Icon = sublink.icon}
<Icon size={12} />
{/if}
{sublink.label}
</a>
</li>
{/each}
</ul>
</details>
{:else}
<a href={link.href} class="flex items-center gap-2">
{#if link.icon}
{@const Icon = link.icon}
<Icon size={14} />
{/if}
{link.label}
</a>
{/if}
</li>
{/each}
</ul>
</div>
<div class="navbar-end">
{@render action?.()}
</div>
</div>
</div>

View File

@@ -0,0 +1,571 @@
<script>
import { onMount } from "svelte";
import { Plus, Trash2, Save, ChevronRight, X, Lock, AlertTriangle } from "@lucide/svelte";
import Select from "./comps/Select.svelte";
import Badge from "./comps/Badge.svelte";
import InfoTip from "./comps/InfoTip.svelte";
let tools = $state([]);
let profiles = $state([]);
let loading = $state(true);
let error = $state("");
let configReadonly = $state(false);
let selectedProfile = $state(null);
let profileDetail = $state(null);
let profileLoading = $state(false);
let notesEdit = $state("");
let enabledEdit = $state([]);
let disabledEdit = $state([]);
let rulesSaving = $state(false);
let rulesMsg = $state(null);
let overrideEdits = $state({});
let overrideSaving = $state({});
let overrideMsg = $state({});
let showNewProfile = $state(false);
let newName = $state("");
let newProfileSaving = $state(false);
let newProfileError = $state("");
let overrideToolNames = $derived(Object.keys(profileDetail?.tools ?? {}));
let configurableTools = $derived(tools.filter((t) => t.config_fields?.length > 0));
let availableForOverride = $derived(configurableTools.filter((t) => !overrideToolNames.includes(t.name)));
let allToolNames = $derived(tools.map((t) => t.name));
let isReadonly = $derived((profileDetail?.readonly ?? false) || configReadonly);
onMount(loadAll);
async function loadAll() {
loading = true;
error = "";
try {
const [tr, pr, cr] = await Promise.all([
fetch("/api/tools"),
fetch("/api/config/profiles"),
fetch("/api/config"),
]);
if (!tr.ok) throw new Error(`HTTP ${tr.status}`);
if (!pr.ok) throw new Error(`HTTP ${pr.status}`);
tools = await tr.json();
profiles = await pr.json();
if (cr.ok) {
const cfg = await cr.json();
configReadonly = cfg.readonly ?? false;
}
if (!selectedProfile && profiles.length > 0) {
const def = profiles.find((p) => p.name === "default");
await selectProfile(def ? "default" : profiles[0].name);
}
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function selectProfile(name) {
selectedProfile = name;
profileLoading = true;
profileDetail = null;
overrideEdits = {};
overrideSaving = {};
overrideMsg = {};
rulesMsg = null;
try {
const res = await fetch(`/api/config/profiles/${encodeURIComponent(name)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
profileDetail = await res.json();
notesEdit = profileDetail.notes ?? "";
enabledEdit = [...(profileDetail.enabled ?? [])];
disabledEdit = [...(profileDetail.disabled ?? [])];
const nextEdits = {};
for (const [toolName, toolConf] of Object.entries(profileDetail.tools ?? {})) {
const tool = tools.find((t) => t.name === toolName);
if (!tool?.config_fields?.length) continue;
nextEdits[toolName] = {};
for (const f of tool.config_fields) {
nextEdits[toolName][f.name] =
toolConf?.[f.name] !== undefined ? toolConf[f.name] : (f.default ?? "");
}
}
overrideEdits = nextEdits;
} catch (e) {
error = e.message;
} finally {
profileLoading = false;
}
}
function validateNewName(name) {
if (!name) return "Name is required";
if (!/^[a-z0-9-]+$/.test(name)) return "Only lowercase letters (a-z), digits (0-9), and hyphens (-) are allowed";
return "";
}
async function createProfile() {
const name = newName.trim();
const nameError = validateNewName(name);
if (nameError) { newProfileError = nameError; return; }
newProfileSaving = true;
newProfileError = "";
try {
const res = await fetch("/api/config/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok)
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
showNewProfile = false;
newName = "";
await loadAll();
await selectProfile(name);
} catch (e) {
newProfileError = e.message;
} finally {
newProfileSaving = false;
}
}
async function deleteProfile(name) {
if (!confirm(`Delete profile "${name}"?`)) return;
try {
await fetch(`/api/config/profiles/${encodeURIComponent(name)}`, { method: "DELETE" });
if (selectedProfile === name) {
selectedProfile = null;
profileDetail = null;
}
await loadAll();
} catch (e) {
error = e.message;
}
}
async function saveRules() {
rulesSaving = true;
rulesMsg = null;
try {
const res = await fetch(`/api/config/profiles/${encodeURIComponent(selectedProfile)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: enabledEdit, disabled: disabledEdit, notes: notesEdit }),
});
if (!res.ok)
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
rulesMsg = { ok: true, text: "Saved" };
setTimeout(() => (rulesMsg = null), 3000);
await selectProfile(selectedProfile);
} catch (e) {
rulesMsg = { ok: false, text: e.message };
} finally {
rulesSaving = false;
}
}
async function saveOverride(toolName) {
const tool = tools.find((t) => t.name === toolName);
for (const f of tool?.config_fields ?? []) {
if (f.required) {
const v = overrideEdits[toolName]?.[f.name];
if (v === undefined || v === null || v === "") {
flashOverride(toolName, { ok: false, text: `"${f.name}" is required` });
return;
}
}
}
overrideSaving = { ...overrideSaving, [toolName]: true };
overrideMsg = { ...overrideMsg, [toolName]: null };
try {
const res = await fetch(
`/api/config/profiles/${encodeURIComponent(selectedProfile)}/tools/${encodeURIComponent(toolName)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(overrideEdits[toolName]),
}
);
if (!res.ok)
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
flashOverride(toolName, { ok: true, text: "Saved" });
} catch (e) {
flashOverride(toolName, { ok: false, text: e.message });
} finally {
overrideSaving = { ...overrideSaving, [toolName]: false };
}
}
async function deleteOverride(toolName) {
if (!confirm(`Remove "${toolName}" override from "${selectedProfile}"?`)) return;
try {
await fetch(
`/api/config/profiles/${encodeURIComponent(selectedProfile)}/tools/${encodeURIComponent(toolName)}`,
{ method: "DELETE" }
);
await selectProfile(selectedProfile);
} catch (e) {
error = e.message;
}
}
function addOverrideFor(toolName) {
if (!toolName) return;
const tool = tools.find((t) => t.name === toolName);
if (!tool) return;
const toolEdits = {};
for (const f of tool.config_fields ?? []) toolEdits[f.name] = f.default ?? "";
overrideEdits = { ...overrideEdits, [toolName]: toolEdits };
profileDetail = {
...profileDetail,
tools: { ...(profileDetail.tools ?? {}), [toolName]: {} },
};
}
function flashOverride(toolName, val) {
overrideMsg = { ...overrideMsg, [toolName]: val };
setTimeout(() => {
overrideMsg = { ...overrideMsg, [toolName]: null };
}, 3000);
}
</script>
{#if loading}
<div class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else}
<div class="flex flex-col md:flex-row gap-0 items-start">
<div class="w-full md:w-52 shrink-0 flex flex-col gap-1 border-b border-base-300 pb-4 mb-4 md:border-b-0 md:border-r md:pb-0 md:mb-0 md:pr-4 md:mr-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs uppercase tracking-widest text-base-content/50">Profiles</span>
{#if !configReadonly}
<button
class="btn btn-ghost btn-xs"
onclick={() => { showNewProfile = !showNewProfile; newName = ""; newProfileError = ""; }}
>
<Plus size={14} />
</button>
{/if}
</div>
{#if showNewProfile && !configReadonly}
<div class="flex flex-col gap-2 p-3 bg-base-300 rounded-box mb-1">
<input
type="text"
class="input input-bordered input-sm w-full {newProfileError && !/^[a-z0-9-]*$/.test(newName) ? 'input-error' : ''}"
placeholder="profile-name"
bind:value={newName}
onkeydown={(e) => e.key === "Enter" && createProfile()}
/>
{#if newProfileError}
<p class="text-xs text-error">{newProfileError}</p>
{/if}
<button
class="btn btn-primary btn-xs w-full"
onclick={createProfile}
disabled={newProfileSaving || !newName.trim()}
>
{#if newProfileSaving}
<span class="loading loading-spinner loading-xs"></span>
{:else}
Create
{/if}
</button>
</div>
{/if}
{#each profiles as p}
<div class="flex items-center gap-1 group">
<button
class="flex-1 btn btn-sm {selectedProfile === p.name ? 'btn-primary' : 'btn-ghost'} justify-start gap-1 truncate"
onclick={() => selectProfile(p.name)}
>
{#if selectedProfile === p.name}
<ChevronRight size={14} class="shrink-0" />
{/if}
{#if p.readonly}
<Lock size={10} class="shrink-0 opacity-50" />
{/if}
{p.name}
</button>
{#if !p.readonly}
<button
class="btn btn-ghost btn-xs text-error opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onclick={() => deleteProfile(p.name)}
>
<Trash2 size={12} />
</button>
{/if}
</div>
{/each}
{#if profiles.length === 0}
<p class="text-base-content/40 text-xs text-center py-4">No profiles yet.</p>
{/if}
</div>
<div class="flex-1 min-w-0 pt-4 md:pt-0">
{#if configReadonly}
<div class="alert alert-warning mb-4 py-2 px-3 text-sm gap-2">
<Lock size={14} class="shrink-0" />
Config is managed externally and is read-only.
</div>
{/if}
{#if !selectedProfile}
<p class="text-base-content/40 text-sm text-center py-8">Select a profile to view it.</p>
{:else if profileLoading}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if profileDetail}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="font-bold text-lg flex items-center gap-2">
{#if isReadonly}<Lock size={14} class="text-base-content/40" />{/if}
{selectedProfile}
</h2>
{#if isReadonly}
<Badge text="read-only" size="sm" />
{/if}
{#if profileDetail.active_tools?.length > 0}
<span class="text-xs text-base-content/50">
{profileDetail.active_tools.length} active tool{profileDetail.active_tools.length !== 1 ? "s" : ""}
</span>
{/if}
</div>
{#if isReadonly}
{#if profileDetail.notes}
<p class="text-sm text-base-content/60 italic">{profileDetail.notes}</p>
{/if}
{:else}
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-widest text-base-content/50">Notes</span>
<textarea
class="textarea textarea-bordered text-sm resize-none"
placeholder="Add a description for this profile..."
rows="2"
bind:value={notesEdit}
></textarea>
</div>
{/if}
<div class="card bg-base-200 shadow">
<div class="card-body gap-4 p-4">
<div class="flex items-center justify-between">
<h3 class="text-xs uppercase tracking-widest text-base-content/50">Rules</h3>
{#if !isReadonly && rulesMsg}
<span class="text-xs {rulesMsg.ok ? 'text-success' : 'text-error'}">{rulesMsg.text}</span>
{/if}
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold">
Enabled
<InfoTip tooltip="Tools in the enabled list will be allowed for this profile. If the enabled list is empty, all tools will be enabled." />
</span>
<div class="flex flex-wrap gap-1 items-center min-h-8">
{#if isReadonly}
{#each (profileDetail.enabled ?? []) as toolName}
<span class="badge badge-outline gap-1">{toolName}</span>
{/each}
{#if (profileDetail.enabled ?? []).length === 0}
<span class="text-xs text-base-content/40">All tools</span>
{/if}
{:else}
{#each enabledEdit as toolName}
<span class="badge badge-outline gap-1">
{toolName}
<button onclick={() => (enabledEdit = enabledEdit.filter((x) => x !== toolName))}>
<X size={10} />
</button>
</span>
{/each}
<Select
options={allToolNames.filter((n) => !enabledEdit.includes(n))}
placeholder="add tool"
size="xs"
onselect={(val) => (enabledEdit = [...enabledEdit, val])}
/>
{/if}
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold">
Disabled
<InfoTip tooltip="Tools in the disabled list will be blocked for this profile. Applied after enabled rules, so if a tool is in both lists, it will be disabled." />
</span>
<div class="flex flex-wrap gap-1 items-center min-h-8">
{#if isReadonly}
{#each (profileDetail.disabled ?? []) as toolName}
<span class="badge badge-error gap-1">{toolName}</span>
{/each}
{#if (profileDetail.disabled ?? []).length === 0}
<span class="text-xs text-base-content/40">None</span>
{/if}
{:else}
{#each disabledEdit as toolName}
<span class="badge badge-error gap-1">
{toolName}
<button onclick={() => (disabledEdit = disabledEdit.filter((x) => x !== toolName))}>
<X size={10} />
</button>
</span>
{/each}
<Select
options={allToolNames.filter((n) => !disabledEdit.includes(n))}
placeholder="add tool"
size="xs"
onselect={(val) => (disabledEdit = [...disabledEdit, val])}
/>
{/if}
</div>
</div>
{#if !isReadonly}
<button
class="btn btn-primary btn-sm gap-1 self-start"
onclick={saveRules}
disabled={rulesSaving}
>
{#if rulesSaving}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Save size={14} />
{/if}
Save
</button>
{/if}
</div>
</div>
{#if !isReadonly}
<div class="card bg-base-200 shadow">
<div class="card-body gap-4 p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-xs uppercase tracking-widest text-base-content/50">Tool overrides</h3>
{#if availableForOverride.length > 0}
<Select
options={availableForOverride.map((t) => t.name)}
placeholder="add override"
size="xs"
onselect={(val) => addOverrideFor(val)}
/>
{/if}
</div>
{#if overrideToolNames.length === 0}
<p class="text-sm text-base-content/40">No overrides configured.</p>
{:else}
<div class="flex flex-col gap-3">
{#each overrideToolNames as toolName}
{@const tool = tools.find((t) => t.name === toolName)}
<div class="border border-base-300 rounded-box p-3 flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-sm">{toolName}</span>
<div class="flex items-center gap-2">
{#if overrideMsg[toolName]}
<span class="text-xs {overrideMsg[toolName].ok ? 'text-success' : 'text-error'}">
{overrideMsg[toolName].text}
</span>
{/if}
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => deleteOverride(toolName)}
>
<Trash2 size={12} />
</button>
</div>
</div>
{#if tool?.config_fields?.length && overrideEdits[toolName]}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
{#each tool.config_fields as field}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-mono text-xs font-semibold">{field.name}</span>
<span class="badge badge-ghost badge-xs">{field.type}</span>
{#if field.required}
<span class="badge badge-error badge-xs">required</span>
{/if}
</div>
{#if field.type === "bool"}
<label class="flex items-center gap-2 cursor-pointer mt-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
bind:checked={overrideEdits[toolName][field.name]}
/>
<span class="text-xs text-base-content/50">
{overrideEdits[toolName][field.name] ? "enabled" : "disabled"}
</span>
</label>
{:else if field.type === "int"}
<input
type="number"
step="1"
class="input input-bordered input-sm font-mono"
bind:value={overrideEdits[toolName][field.name]}
/>
{:else if field.type === "float"}
<input
type="number"
step="any"
class="input input-bordered input-sm font-mono"
bind:value={overrideEdits[toolName][field.name]}
/>
{:else if field.type === "enum"}
<select
class="select select-bordered select-sm font-mono"
bind:value={overrideEdits[toolName][field.name]}
>
{#each field.options as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input
type="text"
class="input input-bordered input-sm font-mono"
bind:value={overrideEdits[toolName][field.name]}
/>
{/if}
</div>
{/each}
</div>
<button
class="btn btn-primary btn-sm gap-1 self-start"
onclick={() => saveOverride(toolName)}
disabled={overrideSaving[toolName]}
>
{#if overrideSaving[toolName]}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Save size={14} />
{/if}
Save
</button>
{:else}
<p class="text-xs text-base-content/40">This tool has no configurable fields.</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,232 @@
<script>
import { Search, AlertTriangle } from "@lucide/svelte";
import Select from "./comps/Select.svelte";
import { INPUT_TYPES } from "@src/lib/vars";
let { onSearch = async () => {}, demo = false } = $props();
const DETECTORS = {
email: (_raw, v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
phone: (_raw, v) => /^\+\d{1,4} \d{4,}$/.test(v),
ip: (_raw, v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]{3,39}$/.test(v),
domain: (raw, v) => /^https?:\/\//.test(raw) || /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v),
name: (_raw, v) => /^[a-zA--ÿ'-]+(?: [a-zA-ZÀ-ÿ'-]+){1,2}$/.test(v),
};
const VALIDATORS = {
email: { test: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), msg: "Invalid email address" },
username: { test: (v) => /^[a-zA-Z0-9._-]+$/.test(v), msg: "Username may only contain a-z, 0-9, . - _" },
phone: { test: (v) => /^\+\d{1,4} \d{4,}$/.test(v), msg: "Format: +INDICATIF NUMERO (ex: +33 0612345678)" },
ip: { test: (v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]{3,39}$/.test(v), msg: "Invalid IP address" },
domain: { test: (v) => /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v), msg: "Invalid domain name" },
};
let target = $state("");
let inputType = $state("email");
let profile = $state("default");
let profiles = $state([]);
let loading = $state(false);
let error = $state("");
let validationError = $state("");
// null = auto-switch free; "TYPE" = auto-switched from TYPE (show revert); "__locked__" = user overrode, no auto-switch
let prevType = $state(null);
let showRevert = $derived(prevType !== null && prevType !== "__locked__");
let profileOptions = $derived(profiles.map((p) => p.name));
let strippedTarget = $derived.by(() => {
let v = target.trim();
v = v.replace(/^https?:\/\//, "");
if (v.startsWith("@")) v = v.slice(1);
return v;
});
let detectedType = $derived.by(() => {
const raw = target.trim();
const v = strippedTarget;
if (!v && !raw) return null;
if (raw.startsWith("@")) return "username";
for (const [type, fn] of Object.entries(DETECTORS)) {
if (fn(raw, v)) return type;
}
return null;
});
async function loadProfiles() {
try {
const res = await fetch("/api/config/profiles");
if (res.ok) profiles = await res.json();
} catch (_) {}
}
loadProfiles();
function sanitize(s) {
return s.replace(/[<>"'`&]/g, "").trim();
}
function validate(val, type) {
const v = VALIDATORS[type];
if (!v) return "";
return v.test(val) ? "" : v.msg;
}
function onTargetInput() {
if (!strippedTarget) prevType = null; // reset when field is cleared
if (validationError) validationError = validate(strippedTarget, inputType);
if (prevType === null && detectedType && detectedType !== inputType) {
prevType = inputType;
inputType = detectedType;
}
}
function revert() {
inputType = prevType;
prevType = "__locked__";
validationError = "";
}
function onSelectType(v) {
inputType = v;
prevType = "__locked__";
validationError = "";
}
async function submit() {
if (demo) return;
const clean = sanitize(strippedTarget);
if (!clean) return;
validationError = validate(clean, inputType);
if (validationError) return;
error = "";
loading = true;
try {
await onSearch(clean, inputType, profile);
target = "";
prevType = null;
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
</script>
<div class="flex flex-col gap-2">
{#if error}
<div class="alert alert-error text-sm py-2 gap-2"><AlertTriangle size={15} class="shrink-0" />{error}</div>
{/if}
{#if showRevert}
<div class="flex items-center gap-2 px-1 text-xs text-base-content/50">
<span>Switched to <span class="text-base-content/70 font-medium">{inputType}</span></span>
<button
class="badge badge-ghost badge-sm hover:badge-primary transition-colors cursor-pointer"
onclick={revert}
>
{prevType}
</button>
</div>
{/if}
<!-- Mobile layout -->
<div class="flex flex-col gap-2 sm:hidden">
<div class="flex items-center rounded-xl border border-base-content/15 bg-base-300
focus-within:border-primary/40 transition-colors">
<input
class="flex-1 bg-transparent px-4 py-3 outline-none text-sm
placeholder:text-base-content/30 min-w-0"
placeholder={demo ? "Search disabled in demo mode" : "Enter target..."}
bind:value={target}
oninput={onTargetInput}
onkeydown={(e) => e.key === "Enter" && submit()}
disabled={demo}
/>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<span class="text-xs text-base-content/25 pl-1">type</span>
<Select
options={INPUT_TYPES}
selected={inputType}
size="xs"
onselect={onSelectType}
/>
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-base-content/25">profile</span>
<Select
options={profileOptions}
selected={profile}
size="xs"
onselect={(v) => { profile = v; }}
/>
</div>
<button
class="btn btn-primary btn-sm flex-1 gap-1"
onclick={submit}
disabled={demo || loading || !target.trim()}
>
{#if loading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Search size={14} />
{/if}
Search
</button>
</div>
</div>
<!-- Desktop layout -->
<div
class="hidden sm:flex items-center rounded-xl border border-base-content/15 bg-base-300
focus-within:border-primary/40 transition-colors"
>
<div class="border-r border-base-content/10 flex items-center gap-1 pl-3">
<span class="text-xs text-base-content/25 shrink-0">type</span>
<Select
options={INPUT_TYPES}
selected={inputType}
onselect={onSelectType}
/>
</div>
<input
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm
placeholder:text-base-content/30 min-w-0"
placeholder={demo ? "Search disabled in demo mode" : "Enter target..."}
bind:value={target}
oninput={onTargetInput}
onkeydown={(e) => e.key === "Enter" && submit()}
disabled={demo}
/>
<div class="border-l border-base-content/10 flex items-center gap-1 pr-1 pl-3">
<span class="text-xs text-base-content/25 shrink-0">profile</span>
<Select
options={profileOptions}
selected={profile}
onselect={(v) => { profile = v; }}
/>
</div>
<button
class="btn btn-primary btn-sm m-1 rounded-lg gap-1 shrink-0"
onclick={submit}
disabled={demo || loading || !target.trim()}
>
{#if loading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Search size={14} />
{/if}
Search
</button>
</div>
{#if validationError}
<p class="text-xs text-error pl-1">{validationError}</p>
{/if}
</div>

View File

@@ -0,0 +1,349 @@
<script>
import { onDestroy, onMount } from "svelte";
import { RefreshCw, ChevronRight, Check, X, AlertTriangle } from "@lucide/svelte";
import TtyOutput from "@src/components/comps/TtyOutput.svelte";
let { id = null } = $props();
let demo = $state(false);
async function checkDemo() {
try {
const res = await fetch("/api/config");
if (res.ok) {
const data = await res.json();
demo = data.demo === true;
}
} catch (_) {}
}
checkDemo();
let resolvedId = $state("");
let search = $state(null);
let error = $state("");
let pollTimeout = null;
let pollDelay = $state(800);
const POLL_MIN = 800;
const POLL_MAX = 5000;
let grouped = $derived(groupByTool(search?.events ?? []));
let toolProgress = $derived(computeProgress(search?.planned_tools ?? [], grouped));
let totalResults = $derived(
(search?.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)
);
let sortedEntries = $derived((() => {
const entries = Object.entries(grouped);
const hasResults = ([toolName, d]) => {
const count = search?.planned_tools?.find(t => t.name === toolName)?.result_count ?? null;
return d.done && (count !== null ? count > 0 : d.output.length > 0);
};
const withResults = entries.filter(([n, d]) => hasResults([n, d]));
const running = entries.filter(([_, d]) => !d.done);
const noResults = entries.filter(([n, d]) => d.done && !hasResults([n, d]));
return { withResults, running, noResults };
})());
function groupByTool(events) {
const map = {};
for (const e of events) {
if (!map[e.tool]) map[e.tool] = { errors: [], output: "", done: false };
if (e.type === "output") map[e.tool].output += e.payload;
else if (e.type === "error") map[e.tool].errors.push(e.payload);
else if (e.type === "done") map[e.tool].done = true;
}
return map;
}
function computeProgress(planned, grouped) {
const skipped = planned.filter((t) => t.skipped);
const active = planned.filter((t) => !t.skipped);
const errored = active.filter((t) => {
const d = grouped[t.name];
return d?.done && d.errors.length > 0 && d.output.length === 0;
});
const done = active.filter((t) => grouped[t.name]?.done);
const running = active.filter((t) => !grouped[t.name]?.done);
const skippedTotal = skipped.length + errored.length;
return { skipped, active, done, running, errored, skippedTotal };
}
async function refresh() {
try {
const res = await fetch(`/api/searches/${resolvedId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
search = await res.json();
if (search.status !== "running") stopPolling();
} catch (e) {
error = e.message;
stopPolling();
}
}
function scheduleNext() {
pollTimeout = setTimeout(async () => {
await refresh();
if (search?.status === "running") {
pollDelay = Math.min(pollDelay * 2, POLL_MAX);
scheduleNext();
}
}, pollDelay);
}
function startPolling() { stopPolling(); pollDelay = POLL_MIN; scheduleNext(); }
function stopPolling() { if (pollTimeout) { clearTimeout(pollTimeout); pollTimeout = null; } }
onMount(async () => {
resolvedId = id ?? window.location.pathname.replace(/^\/search\//, "").replace(/\/$/, "");
await refresh();
if (search?.status === "running") startPolling();
});
onDestroy(stopPolling);
let toast = $state(null); // { msg, type }
async function cancel() {
stopPolling();
await fetch(`/api/searches/${resolvedId}`, { method: "DELETE" });
toast = { msg: "Search cancelled.", type: "alert-warning" };
setTimeout(() => { window.location.href = "/"; }, 2000);
}
function fmtDate(iso) {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
const STATUS_BADGE = {
running: "badge-warning",
done: "badge-success",
cancelled: "badge-error",
};
</script>
{#if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else if !search}
<div class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-1 flex-wrap">
<h1 class="font-mono text-xl sm:text-2xl font-bold truncate">{search.target}</h1>
<span class="badge {STATUS_BADGE[search.status] ?? 'badge-ghost'} shrink-0">
{#if search.status === "running"}
<span class="loading loading-ring loading-xs mr-1"></span>
{/if}
{search.status}
</span>
</div>
<div class="flex items-center gap-2 flex-wrap text-sm text-base-content/50">
<span>{search.input_type}</span>
{#if search.profile}
<span class="badge badge-outline badge-sm font-semibold">{search.profile}</span>
{/if}
<span>· started {fmtDate(search.started_at)}</span>
{#if search.status !== "running" && totalResults > 0}
<span>· <span class="text-base-content/70 font-medium">{totalResults} result{totalResults !== 1 ? "s" : ""}</span></span>
{/if}
</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-sm btn-ghost gap-1" onclick={refresh}>
<RefreshCw size={14} /> Refresh
</button>
{#if search.status === "running"}
<button class="btn btn-sm btn-error btn-outline gap-1" onclick={cancel}>
<X size={14} /> Cancel
</button>
{/if}
</div>
</div>
{#if search.planned_tools?.length > 0}
<div class="card bg-base-200 shadow mb-6">
<div class="card-body p-4 gap-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Tools</h2>
<div class="flex gap-3 text-sm">
<span class="text-success font-mono">
{toolProgress.done.length}/{toolProgress.active.length} done
</span>
{#if toolProgress.skippedTotal > 0}
<span class="text-warning font-mono">
{toolProgress.skippedTotal} skipped
</span>
{/if}
</div>
</div>
<div class="w-full h-2 bg-base-300 rounded-full overflow-hidden flex relative">
<div class="h-full bg-success transition-all duration-500" style="width:{toolProgress.active.length > 0 ? (toolProgress.done.length - toolProgress.errored.length) / toolProgress.active.length * 100 : 0}%"></div>
<div class="h-full bg-warning/70 transition-all duration-500" style="width:{toolProgress.active.length > 0 ? toolProgress.errored.length / toolProgress.active.length * 100 : 0}%"></div>
{#if search.status === "running"}
<div class="shimmer absolute inset-0 pointer-events-none"></div>
{/if}
</div>
<details class="group">
<summary
class="flex items-center gap-1 cursor-pointer text-xs text-base-content/40
hover:text-base-content/70 transition-colors list-none select-none w-fit"
>
<ChevronRight size={12} class="transition-transform duration-200 group-open:rotate-90" />
Show tools
</summary>
<div class="flex flex-wrap gap-2 pt-3">
{#each search.planned_tools as t}
{@const d = grouped[t.name]}
{@const isErrored = d?.done && d.errors.length > 0 && d.output.length === 0}
{#if t.skipped}
<div class="tooltip" data-tip={t.reason}>
<span class="badge badge-warning badge-sm font">{t.name}</span>
</div>
{:else if isErrored}
<div class="tooltip" data-tip={d.errors[0] ?? "error"}>
<span class="badge badge-warning badge-sm">{t.name}</span>
</div>
{:else if d?.done}
<a href="/tools/{t.name}" class="badge badge-success badge-sm hover:badge-outline transition-all gap-1">
<Check size={10} />{t.name}
</a>
{:else if search.status === "running"}
<a href="/tools/{t.name}" class="badge badge-ghost badge-sm hover:badge-outline transition-all">
<span class="loading loading-ring loading-xs mr-1"></span>{t.name}
</a>
{:else}
<a href="/tools/{t.name}" class="badge badge-ghost badge-sm hover:badge-outline transition-all">
{t.name}
</a>
{/if}
{/each}
</div>
</details>
{#if demo}
<p class="text-xs text-base-content/40 italic">Results shown are not exhaustive — demo mode only displays a subset of what the tools can find.</p>
{/if}
</div>
</div>
{/if}
{#if Object.keys(grouped).length === 0 && search.status === "running"}
<p class="text-base-content/40 text-sm text-center py-8">Waiting for results...</p>
{:else if Object.keys(grouped).length === 0}
<p class="text-base-content/40 text-sm text-center py-8">No results.</p>
{:else}
<div class="flex flex-col gap-4">
{#each sortedEntries.withResults as [toolName, data]}
{@render toolCard(toolName, data)}
{/each}
{#each sortedEntries.running as [toolName, data]}
{@render toolCard(toolName, data)}
{/each}
{#if sortedEntries.noResults.length > 0}
<details class="group">
<summary
class="flex items-center gap-2 cursor-pointer select-none list-none
text-sm text-base-content/40 hover:text-base-content/60 transition-colors"
>
<ChevronRight size={14} class="transition-transform duration-200 group-open:rotate-90" />
No results
<span class="font-mono text-xs">({sortedEntries.noResults.length})</span>
</summary>
<div class="flex flex-col gap-3 pt-3">
{#each sortedEntries.noResults as [toolName, data]}
{@render toolCard(toolName, data)}
{/each}
</div>
</details>
{/if}
</div>
{/if}
{/if}
{#snippet toolCard(toolName, data)}
{@const toolStatus = search.planned_tools?.find((t) => t.name === toolName)}
{@const resultCount = data.output.length > 0 ? (toolStatus?.result_count ?? null) : null}
<div class="card bg-base-200 shadow">
<details class="group" open>
<summary class="card-body gap-3 p-4 cursor-pointer list-none">
<div class="flex items-center gap-3 flex-wrap">
<ChevronRight size={14} class="shrink-0 opacity-40 transition-transform duration-200 group-open:rotate-90" />
<a href="/tools/{toolName}" class="font-bold hover:underline underline-offset-2" onclick={(e) => e.stopPropagation()}>
{toolName}
</a>
{#if !data.done}
<span class="badge badge-warning badge-sm">
<span class="loading loading-ring loading-xs mr-1"></span>running
</span>
{:else if data.errors.length > 0 && data.output.length === 0}
<span class="badge badge-error badge-sm">error</span>
{:else}
<span class="badge badge-success badge-sm">done</span>
{/if}
<span class="text-xs text-base-content/40 ml-auto">
{#if resultCount === null}
output
{:else}
{resultCount} result{resultCount !== 1 ? "s" : ""}
{/if}
{#if data.errors.length > 0}
· <span class="text-error">{data.errors.length} error{data.errors.length !== 1 ? "s" : ""}</span>
{/if}
</span>
</div>
</summary>
<div class="px-4 pb-4 flex flex-col gap-3">
{#each data.errors as err}
<div class="alert alert-error py-2 text-sm gap-2"><AlertTriangle size={14} class="shrink-0" />{err}</div>
{/each}
{#if data.output.length > 0}
<TtyOutput output={data.output} />
{:else if data.done && data.errors.length === 0}
<p class="text-sm text-base-content/40">No results.</p>
{/if}
</div>
</details>
</div>
{/snippet}
{#if toast}
<div class="toast toast-end toast-bottom z-50">
<div class="alert {toast.type} shadow-lg">
<span>{toast.msg}</span>
</div>
</div>
{/if}
<style>
.shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.18) 50%,
transparent 100%
);
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
</style>

View File

@@ -0,0 +1,71 @@
<script>
import { INPUT_TYPE_ICON } from "@src/lib/vars";
import { FileText, X } from "@lucide/svelte";
let { searches = [], onDelete = async () => {} } = $props();
const STATUS_BADGE = {
running: "badge-warning",
done: "badge-success",
cancelled: "badge-error",
};
function fmtDate(iso) {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
</script>
{#if searches.length === 0}
<p class="text-base-content/40 text-sm text-center py-8">No searches yet. Run one above.</p>
{:else}
<div class="flex flex-col gap-2">
{#each searches as s (s.id)}
<a
href={`/search/${s.id}`}
class="card bg-base-200 hover:bg-base-300 transition-colors shadow-sm cursor-pointer"
>
<div class="card-body flex-row items-center gap-4 py-3 px-4">
<div class="text-base-content/40 w-6 flex items-center justify-center shrink-0">
{#each [INPUT_TYPE_ICON[s.input_type] ?? FileText] as Icon}
<Icon size={16} />
{/each}
</div>
<div class="flex flex-col min-w-0 flex-1">
<span class="font-mono font-semibold truncate">{s.target}</span>
<div class="flex items-center gap-1.5 flex-wrap text-xs text-base-content/50">
<span>{s.input_type}</span>
{#if s.profile}
<span class="badge badge-outline badge-xs font-semibold">{s.profile}</span>
{/if}
<span>· {fmtDate(s.started_at)}</span>
</div>
</div>
{#if s.status !== "running"}
{@const total = (s.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)}
{#if total > 0}
<span class="text-xs font-mono text-base-content/50 shrink-0">{total} result{total !== 1 ? "s" : ""}</span>
{/if}
{/if}
<span class="badge {STATUS_BADGE[s.status] ?? 'badge-ghost'} badge-sm shrink-0">
{#if s.status === "running"}
<span class="loading loading-ring loading-xs mr-1"></span>
{/if}
{s.status}
</span>
<button
class="btn btn-ghost btn-xs text-base-content/30 hover:text-error shrink-0"
onclick={(e) => { e.preventDefault(); onDelete(s.id); }}
title="Delete"
><X size={14} /></button>
</div>
</a>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,265 @@
<script>
import { onMount } from "svelte";
import { Save, Trash2, AlertTriangle, Package } from "@lucide/svelte";
import ToolIcon from "./comps/ToolIcon.svelte";
let { name = null } = $props();
let resolvedName = $state("");
let tool = $state(null);
let error = $state("");
let edits = $state({});
let saving = $state(false);
let msg = $state(null);
let hasGlobalConfig = $state(false);
let configReadonly = $state(false);
onMount(async () => {
resolvedName = name ?? window.location.pathname.replace(/^\/tools\//, "").replace(/\/$/, "");
try {
const [toolRes, cfgRes] = await Promise.all([
fetch(`/api/tools/${encodeURIComponent(resolvedName)}`),
fetch("/api/config"),
]);
if (!toolRes.ok) {
const body = await toolRes.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${toolRes.status}`);
}
tool = await toolRes.json();
const cfg = cfgRes.ok ? await cfgRes.json() : { tools: {} };
configReadonly = cfg.readonly ?? false;
const curMap = cfg.tools?.[resolvedName] ?? {};
hasGlobalConfig = !!cfg.tools?.[resolvedName];
const next = {};
for (const f of tool.config_fields ?? []) {
const saved = curMap[f.name];
next[f.name] = saved !== undefined && saved !== null
? saved
: (f.value !== undefined && f.value !== null ? f.value : (f.default ?? defaultForType(f.type)));
}
edits = next;
} catch (e) {
error = e.message;
}
});
function defaultForType(type) {
if (type === "bool") return false;
if (type === "int" || type === "float") return 0;
return "";
}
async function save() {
saving = true;
msg = null;
try {
const res = await fetch(`/api/config/tools/${encodeURIComponent(resolvedName)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(edits),
});
if (!res.ok)
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
hasGlobalConfig = true;
msg = { ok: true, text: "Saved" };
} catch (e) {
msg = { ok: false, text: e.message };
} finally {
saving = false;
setTimeout(() => (msg = null), 3000);
}
}
async function clearConfig() {
if (!confirm(`Clear global config for "${resolvedName}"?`)) return;
try {
await fetch(`/api/config/tools/${encodeURIComponent(resolvedName)}`, { method: "DELETE" });
hasGlobalConfig = false;
const next = {};
for (const f of tool.config_fields ?? []) next[f.name] = f.default ?? defaultForType(f.type);
edits = next;
msg = { ok: true, text: "Cleared" };
} catch (e) {
msg = { ok: false, text: e.message };
} finally {
setTimeout(() => (msg = null), 3000);
}
}
</script>
{#if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else if !tool}
<div class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="flex flex-col gap-6 max-w-2xl">
{#if tool.available === false}
<div class="alert alert-error gap-3">
<AlertTriangle size={18} class="shrink-0" />
<div>
<p class="font-semibold text-sm">Tool unavailable</p>
{#if tool.unavailable_reason}
<p class="text-sm opacity-80">{tool.unavailable_reason}</p>
{/if}
</div>
</div>
{/if}
<div class="flex items-start justify-between gap-4 flex-wrap">
<div class="flex items-center gap-3">
<ToolIcon iconName={tool.icon} size={32} />
<div class="pl-4">
<h1 class="text-2xl sm:text-3xl font-bold mb-1">{tool.name}</h1>
{#if tool.description}
<p class="text-base-content/60">{tool.description}</p>
{/if}
</div>
</div>
{#if tool.link}
<a href={tool.link} target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-sm gap-1">
↗ source
</a>
{/if}
</div>
<div class="card bg-base-200">
<div class="card-body gap-3 p-4">
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Accepted input types</h2>
<div class="flex flex-wrap gap-2">
{#each tool.input_types as t}
<span class="badge badge-outline border-base-content/20">{t}</span>
{/each}
</div>
</div>
</div>
{#if tool.dependencies?.length > 0}
<div class="card bg-base-200">
<div class="card-body gap-3 p-4">
<h2 class="text-xs uppercase tracking-widest text-base-content/50 flex items-center gap-2">
<Package size={13} /> External dependencies
</h2>
<ul class="flex flex-col gap-1">
{#each tool.dependencies as dep}
<li class="flex items-center gap-2">
<span class="font-mono text-sm bg-base-300 px-2 py-0.5 rounded">{dep}</span>
<span class="text-xs text-base-content/40">must be in <code>$PATH</code></span>
</li>
{/each}
</ul>
</div>
</div>
{/if}
{#if tool.config_fields?.length > 0}
<div class="card bg-base-200">
<div class="card-body gap-4 p-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-2">
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Global config</h2>
{#if hasGlobalConfig}
<span class="badge badge-outline badge-xs">configured</span>
{/if}
{#if configReadonly}
<span class="badge badge-ghost badge-xs">read-only</span>
{/if}
</div>
{#if msg}
<span class="text-xs {msg.ok ? 'text-success' : 'text-error'}">{msg.text}</span>
{/if}
</div>
{#each tool.config_fields as field}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono font-semibold text-sm">{field.name}</span>
<span class="badge badge-ghost badge-xs">{field.type}</span>
{#if field.required}
<span class="badge badge-error badge-xs">required</span>
{/if}
</div>
{#if field.description}
<p class="text-sm text-base-content/60">{field.description}</p>
{/if}
<div class="text-xs text-base-content/40 font-mono mb-1">
default: {field.default ?? "-"}
</div>
{#if field.type === "bool"}
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
bind:checked={edits[field.name]}
disabled={configReadonly}
/>
<span class="text-sm text-base-content/50">
{edits[field.name] ? "enabled" : "disabled"}
</span>
</label>
{:else if field.type === "int"}
<input
type="number"
step="1"
class="input input-bordered input-sm font-mono w-full max-w-48"
bind:value={edits[field.name]}
/>
{:else if field.type === "float"}
<input
type="number"
step="any"
class="input input-bordered input-sm font-mono w-full max-w-48"
bind:value={edits[field.name]}
/>
{:else if field.type === "enum"}
<select
class="select select-bordered select-sm font-mono w-full max-w-xs"
bind:value={edits[field.name]}
>
{#each field.options as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input
type="text"
class="input input-bordered input-sm font-mono w-full max-w-xs"
bind:value={edits[field.name]}
/>
{/if}
</div>
{/each}
{#if !configReadonly}
<div class="flex gap-2 pt-1 flex-wrap">
<button
class="btn btn-primary btn-sm gap-1"
onclick={save}
disabled={saving}
>
{#if saving}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Save size={14} />
{/if}
Save
</button>
{#if hasGlobalConfig}
<button class="btn btn-ghost btn-sm gap-1 text-error" onclick={clearConfig}>
<Trash2 size={14} /> Reset to defaults
</button>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,346 @@
<script>
import { onMount } from "svelte";
import { AlertTriangle } from "@lucide/svelte";
import Select from "./comps/Select.svelte";
import { INPUT_TYPES } from "@src/lib/vars";
import ToolIcon from "./comps/ToolIcon.svelte";
let tools = $state([]);
let config = $state({ tools: {}, profiles: {} });
let profileSummaries = $state([]);
let selectedProfile = $state("default");
let profileDetail = $state(null);
let loading = $state(true);
let profileLoading = $state(false);
let error = $state("");
let selectedInputType = $state("all");
const inputTypeOptions = ["all", ...INPUT_TYPES];
let profileOptions = $derived(profileSummaries.map((p) => p.name));
let activeSet = $derived(
profileDetail
? new Set(profileDetail.active_tools ?? [])
: new Set(tools.map((t) => t.name)),
);
let globalToolConf = $derived(config.tools ?? {});
let profileOverrides = $derived(profileDetail?.tools ?? {});
let toolsWithStatus = $derived(
tools.map((tool) => {
const isActive = activeSet.has(tool.name);
const effective = {
...(globalToolConf[tool.name] ?? {}),
...(profileOverrides[tool.name] ?? {}),
};
const missingConfig = (tool.config_fields ?? []).some((f) => {
if (!f.required) return false;
const v = effective[f.name];
return v === undefined || v === null || v === "";
});
const unavailable = tool.available === false;
return { ...tool, isActive, missingConfig, unavailable };
}),
);
let visibleTools = $derived(
selectedInputType === "all"
? toolsWithStatus
: toolsWithStatus.filter((t) =>
t.input_types.includes(selectedInputType),
),
);
let active = $derived(
visibleTools.filter(
(t) => t.isActive && !t.missingConfig && !t.unavailable,
),
);
let activeMissing = $derived(
visibleTools.filter((t) => t.isActive && t.missingConfig && !t.unavailable),
);
let activeUnavail = $derived(
visibleTools.filter((t) => t.isActive && t.unavailable),
);
let inactive = $derived(
visibleTools.filter(
(t) => !t.isActive && !t.missingConfig && !t.unavailable,
),
);
let inactiveMissing = $derived(
visibleTools.filter(
(t) => !t.isActive && t.missingConfig && !t.unavailable,
),
);
let inactiveUnavail = $derived(
visibleTools.filter((t) => !t.isActive && t.unavailable),
);
onMount(async () => {
try {
const [tr, cr, pr] = await Promise.all([
fetch("/api/tools"),
fetch("/api/config"),
fetch("/api/config/profiles"),
]);
if (!tr.ok) throw new Error(`HTTP ${tr.status}`);
if (!cr.ok) throw new Error(`HTTP ${cr.status}`);
if (!pr.ok) throw new Error(`HTTP ${pr.status}`);
tools = await tr.json();
config = await cr.json();
profileSummaries = await pr.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
});
async function selectProfile(name) {
selectedProfile = name;
profileLoading = true;
try {
const res = await fetch(
`/api/config/profiles/${encodeURIComponent(name)}`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
profileDetail = await res.json();
} catch (e) {
error = e.message;
} finally {
profileLoading = false;
}
}
</script>
{#snippet toolCard(tool, missing)}
<div
class="card bg-base-200 group-hover:bg-base-300 transition-colors shadow-sm h-full
{missing ? 'border border-warning/40' : ''}
{tool.unavailable ? 'border border-error/40' : ''}"
>
<div class="card-body p-4 flex-row items-start gap-0">
<div
class="size-10 rounded-lg bg-base-300 group-hover:bg-base-200 transition-colors
flex items-center justify-center shrink-0 mr-3 mt-0.5"
>
<ToolIcon iconName={tool.icon} size={20} />
</div>
<div class="flex flex-col min-w-0 flex-1 gap-1.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-bold text-sm leading-tight">{tool.name}</span>
{#if tool.unavailable}
<span class="badge badge-error badge-xs gap-1">
<AlertTriangle size={9} /> unavailable
</span>
{:else if missing}
<span class="badge badge-warning badge-xs gap-1">
<AlertTriangle size={9} /> config required
</span>
{/if}
</div>
{#if tool.unavailable && tool.unavailable_reason}
<p class="text-xs text-error/70 leading-relaxed">
{tool.unavailable_reason}
</p>
{:else if tool.description}
<p class="text-xs text-base-content/50 line-clamp-2 leading-relaxed">
{tool.description}
</p>
{/if}
<div class="flex flex-wrap gap-1">
{#each tool.input_types as t}
<span class="badge badge-xs badge-outline border-base-content/20">{t}</span>
{/each}
</div>
</div>
</div>
</div>
{/snippet}
{#if loading}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else}
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 mb-6">
<div class="flex items-center gap-3">
<span
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
>Profile</span
>
<Select
options={profileOptions}
selected={selectedProfile}
onselect={selectProfile}
/>
{#if profileLoading}
<span class="loading loading-spinner loading-xs opacity-40"></span>
{/if}
</div>
<div class="flex items-center gap-3">
<span
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
>Input</span
>
<Select
options={inputTypeOptions}
selected={selectedInputType}
onselect={(val) => (selectedInputType = val)}
/>
</div>
</div>
{#if tools.length === 0}
<p class="text-base-content/40 text-sm text-center py-8">
No tools registered.
</p>
{:else}
<div class="flex flex-col gap-6">
{#if active.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-success shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-base-content/50"
>Active</span
>
<span class="text-xs text-base-content/30">{active.length}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each active as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if activeMissing.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-warning shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-base-content/50"
>Active - required config missing</span
>
<span class="text-xs text-base-content/30"
>{activeMissing.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each activeMissing as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, true)}
</a>
{/each}
</div>
</section>
{/if}
{#if activeUnavail.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-error shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-error/70"
>Active - unavailable</span
>
<span class="text-xs text-base-content/30"
>{activeUnavail.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each activeUnavail as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if active.length + activeMissing.length + activeUnavail.length > 0 && inactive.length + inactiveMissing.length + inactiveUnavail.length > 0}
<div class="divider opacity-20 my-0"></div>
{/if}
{#if inactive.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled</span
>
<span class="text-xs text-base-content/20">{inactive.length}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactive as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if inactiveMissing.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled - required config missing</span
>
<span class="text-xs text-base-content/20"
>{inactiveMissing.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactiveMissing as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, true)}
</a>
{/each}
</div>
</section>
{/if}
{#if inactiveUnavail.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled - unavailable</span
>
<span class="text-xs text-base-content/20"
>{inactiveUnavail.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactiveUnavail as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
</div>
{/if}
{/if}

View File

@@ -0,0 +1,29 @@
<script>
let { text, color = null, icon: IconComponent = null, size = "sm", loading = false } = $props();
const colorDefaults = {
"done": "badge-success",
"error": "badge-error",
"unavailable": "badge-error",
"running": "badge-warning",
"cancelled": "badge-error",
"required": "badge-error",
"read-only": "badge-ghost",
"active": "badge-success",
"disabled": "badge-ghost",
"configured": "badge-outline",
"configurable": "badge-outline",
"config required":"badge-warning",
};
let cls = $derived(color ?? colorDefaults[text?.toLowerCase()] ?? "badge-ghost");
</script>
<span class="badge badge-{size} {cls} gap-1">
{#if loading}
<span class="loading loading-ring loading-xs"></span>
{:else if IconComponent}
<IconComponent size={9} />
{/if}
{text}
</span>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { CircleQuestionMark } from "@lucide/svelte";
let {
tooltip,
}: {
tooltip: string;
} = $props();
</script>
<div class="tooltip" data-tip={tooltip}>
<CircleQuestionMark class="size-3 text-base-content/40 align-middle"/>
</div>

View File

@@ -0,0 +1,84 @@
<script>
import { ChevronDown } from "@lucide/svelte";
let {
options = [],
placeholder = "",
selected = null,
onselect,
size = "sm", // "xs" | "sm" | ""
} = $props();
let open = $state(false);
let query = $state("");
let container;
let input = $state();
let filtered = $derived(options.filter((o) =>
o.toLowerCase().includes(query.toLowerCase())
));
function select(value) {
onselect?.(value);
open = false;
query = "";
}
function toggle() {
open = !open;
if (open) setTimeout(() => input?.focus(), 0);
}
</script>
<svelte:window onmousedown={(e) => {
if (container && !container.contains(e.target)) {
open = false;
query = "";
}
}} />
<div class="relative" bind:this={container}>
<button
type="button"
class="btn btn-ghost btn-{size} gap-1 font-normal"
onclick={toggle}
>
<ChevronDown size={11} />
{selected ?? placeholder}
</button>
{#if open}
<div
class="absolute z-50 top-full left-0 mt-1 w-52 bg-base-300 rounded-box shadow-xl border border-base-content/10 flex flex-col"
>
<div class="p-2 border-b border-base-content/10">
<input
bind:this={input}
type="text"
class="input input-bordered input-xs w-full"
placeholder="Search..."
bind:value={query}
onkeydown={(e) => { if (e.key === "Escape") { open = false; query = ""; } }}
/>
</div>
<ul class="max-h-48 overflow-y-auto p-1">
{#if filtered.length === 0}
<li class="px-3 py-2 text-xs text-base-content/40 text-center">No results</li>
{:else}
{#each filtered as option}
<li>
<button
type="button"
class="w-full text-left px-3 py-1.5 text-sm font-mono rounded-btn transition-colors
{option === selected ? 'bg-primary/15 text-primary font-semibold' : 'hover:bg-base-content/10'}"
onclick={() => select(option)}
>
{option}
</button>
</li>
{/each}
{/if}
</ul>
</div>
{/if}
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
const { iconName = "", size=16 }: { iconName: string , size: number} = $props();
const genericFallbackUrl = "/Wrench.svg";
</script>
{#if iconName}
<img
src="https://cdn.simpleicons.org/{iconName}"
alt={iconName + " icon"}
class="opacity-50"
width={size}
height={size}
style="filter: brightness(0) invert(1);"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.src = genericFallbackUrl;
}}
/>
{:else}
<img
src={genericFallbackUrl}
alt={"Tool icon"}
class="opacity-50"
width={size}
height={size}
style="filter: brightness(0) invert(1);"
/>
{/if}

View File

@@ -0,0 +1,63 @@
<script>
import { AnsiUp } from "ansi_up";
import DOMPurify from "dompurify";
let { output } = $props();
const au = new AnsiUp();
au.use_classes = false;
const ansiRe = /\x1b\[[0-9;]*m/g;
const urlRe = /https?:\/\/[^\s<>"']+/g;
const tlds = "com|org|net|io|fr|de|uk|co|info|biz|eu|us|ca|au|ru|cn|jp|br|in|es|dev|app|me|tv|cc|ch|be|nl|se|no|dk|fi|pl|cz|at|hu|ro";
const bareDomainRe = new RegExp(`(?<![a-zA-Z0-9@])[a-zA-Z0-9][a-zA-Z0-9\\-]*(?:\\.[a-zA-Z0-9][a-zA-Z0-9\\-]*)*\\.(?:${tlds})(?:/[^\\s<>"']*)?`, "g");
const makeLink = (href, text) =>
`<a href="${href}" target="_blank" rel="noopener noreferrer" class="ansi-link">${text}</a>`;
function linkifyText(text) {
// First pass: full URLs
const afterUrls = text.replace(urlRe, (url) => makeLink(url, url));
// Re-split to protect the newly created <a> tags, then linkify bare domains in remaining text
return afterUrls.split(/(<[^>]+>)/).map((part) => {
if (part.startsWith("<")) return part;
return part.replace(bareDomainRe, (domain) => makeLink(`https://${domain}`, domain));
}).join("");
}
function linkify(html) {
return html.split(/(<[^>]+>)/).map((part) =>
part.startsWith("<") ? part : linkifyText(part)
).join("");
}
let html = $derived((() => {
const lines = output.split("\n");
let start = 0;
let end = lines.length - 1;
while (start <= end && lines[start].replace(ansiRe, "").trim() === "") start++;
while (end >= start && lines[end].replace(ansiRe, "").trim() === "") end--;
return DOMPurify.sanitize(linkify(au.ansi_to_html(lines.slice(start, end + 1).join("\n"))), {
ALLOWED_TAGS: ["span", "a", "b"],
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
});
})());
</script>
{#if html}
<div class="ansi-output">{@html html}</div>
{/if}
<style>
.ansi-output {
line-height: 1.35;
}
.ansi-output :global(.ansi-link) {
text-decoration: underline;
text-underline-offset: 2px;
opacity: 0.8;
}
.ansi-output :global(.ansi-link:hover) {
opacity: 1;
}
</style>