mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-12 00:47:26 +02:00
233 lines
7.3 KiB
Svelte
233 lines
7.3 KiB
Svelte
<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, initialTarget = "", initialType = "" } = $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-ZÀ-ÿ'-]+(?: [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(initialTarget);
|
|
let inputType = $state(initialType || "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>
|