chat-ui / src /lib /components /chat /UrlFetchModal.svelte
coyotte508
A new start
fc69895
<script lang="ts">
import Modal from "../Modal.svelte";
import { base } from "$app/paths";
import { tick } from "svelte";
interface Props {
open?: boolean;
acceptMimeTypes?: string[]; // optional client-side validation
onclose?: () => void;
onfiles?: (files: File[]) => void;
}
let { open = $bindable(false), acceptMimeTypes = [], onclose, onfiles }: Props = $props();
let urlValue = $state("");
let loading = $state(false);
let errorMsg = $state("");
let inputEl: HTMLInputElement | undefined = $state();
async function focusInputSoon() {
// Wait for modal and content to mount, then focus and select
await tick();
await tick();
setTimeout(() => {
inputEl?.focus();
inputEl?.select();
}, 0);
}
$effect(() => {
if (open) {
// reset state when opening
urlValue = "";
errorMsg = "";
void focusInputSoon();
}
});
function isHttpsUrl(url: string) {
try {
const u = new URL(url);
return u.protocol === "https:";
} catch {
return false;
}
}
function matchesAllowed(contentType: string, allowed: string[]): boolean {
const ct = contentType.split(";")[0]?.trim().toLowerCase();
if (!ct) return false;
const [ctType, ctSubtype] = ct.split("/");
for (const a of allowed) {
const [aType, aSubtype] = a.toLowerCase().split("/");
const typeOk = aType === "*" || aType === ctType;
const subOk = aSubtype === "*" || aSubtype === ctSubtype;
if (typeOk && subOk) return true;
}
return false;
}
function close() {
open = false;
onclose?.();
}
async function handleSubmit() {
errorMsg = "";
const trimmed = urlValue.trim();
if (!isHttpsUrl(trimmed)) {
errorMsg = "Enter a valid HTTPS URL.";
return;
}
loading = true;
try {
// Use server proxy directly for one URL to validate size/types before creating File
const params = new URLSearchParams({ url: trimmed });
if (acceptMimeTypes.length > 0) params.set("accept", acceptMimeTypes.join(","));
const proxyUrl = `${base}/api/fetch-url?${params}`;
const res = await fetch(proxyUrl);
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || `Failed to fetch (${res.status})`);
}
const blob = await res.blob();
// Optional client-side mime filter (same wildcard semantics as dropzone)
if (acceptMimeTypes.length > 0 && blob.type && !matchesAllowed(blob.type, acceptMimeTypes)) {
throw new Error("File type not allowed.");
}
const disp = res.headers.get("content-disposition");
let filename = "attachment";
const match = disp?.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) filename = match[1].replace(/['"]/g, "");
else {
try {
const u = new URL(trimmed);
const last = u.pathname.split("/").pop() || "attachment";
filename = decodeURIComponent(last);
} catch {}
}
const file = new File([blob], filename, { type: blob.type || "application/octet-stream" });
onfiles?.([file]);
close();
} catch (e) {
errorMsg = e instanceof Error ? e.message : "Failed to fetch URL";
} finally {
loading = false;
}
}
</script>
{#if open}
<Modal onclose={close} width="w-[90dvh] md:w-[480px]">
{#snippet children()}
<form
class="flex w-full flex-col gap-5 p-6"
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="flex items-start justify-between">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Add from URL</h2>
<button type="button" class="group" onclick={close} aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
class="size-5 text-gray-700 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400"
>
<path
d="M24 9.41 22.59 8 16 14.59 9.41 8 8 9.41 14.59 16 8 22.59 9.41 24 16 17.41 22.59 24 24 22.59 17.41 16 24 9.41z"
fill="currentColor"
/>
</svg>
</button>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400" for="fetch-url-input"
>Enter URL</label
>
<input
id="fetch-url-input"
bind:this={inputEl}
bind:value={urlValue}
type="url"
placeholder="https://example.com/file.txt"
class="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-[15px] text-gray-800 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:ring-gray-700"
aria-invalid={errorMsg ? "true" : "false"}
onkeydown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
{#if errorMsg}
<p class="-mt-1 text-sm text-red-600 dark:text-red-400">{errorMsg}</p>
{/if}
<p class="-mt-2 text-xs text-gray-500 dark:text-gray-400">Only HTTPS. Max 10MB.</p>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="inline-flex items-center rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
onclick={close}
>
Cancel
</button>
<button
type="submit"
class="inline-flex items-center rounded-xl border border-gray-900 bg-gray-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
disabled={loading || urlValue.trim() === ""}
>
{#if loading}Fetching…{:else}Add{/if}
</button>
</div>
</form>
{/snippet}
</Modal>
{/if}
<style lang="postcss">
:global(input) {
font-family: inherit;
}
/* Uses app-level colors and rounded/blur styles via utility classes */
/* The Modal itself provides consistent container + scrollbar-custom styling */
</style>