mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-12 00:47:26 +02:00
init
This commit is contained in:
232
front/src/components/SearchBar.svelte
Normal file
232
front/src/components/SearchBar.svelte
Normal 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-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("");
|
||||
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>
|
||||
Reference in New Issue
Block a user