| | <script lang="ts"> |
| | import Modal from "../Modal.svelte"; |
| | import { base } from "$app/paths"; |
| | import { tick } from "svelte"; |
| | |
| | interface Props { |
| | open?: boolean; |
| | acceptMimeTypes?: string[]; |
| | 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() { |
| | |
| | await tick(); |
| | await tick(); |
| | setTimeout(() => { |
| | inputEl?.focus(); |
| | inputEl?.select(); |
| | }, 0); |
| | } |
| | |
| | $effect(() => { |
| | if (open) { |
| | |
| | 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 { |
| | |
| | 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(); |
| | |
| | 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; |
| | } |
| | |
| | |
| | </style> |
| |
|