|
| 1 | +<script lang="ts"> |
| 2 | + import Modal from "../Modal.svelte"; |
| 3 | + import { base } from "$app/paths"; |
| 4 | + import { tick } from "svelte"; |
| 5 | +
|
| 6 | + interface Props { |
| 7 | + open?: boolean; |
| 8 | + acceptMimeTypes?: string[]; // optional client-side validation |
| 9 | + onclose?: () => void; |
| 10 | + onfiles?: (files: File[]) => void; |
| 11 | + } |
| 12 | +
|
| 13 | + let { open = $bindable(false), acceptMimeTypes = [], onclose, onfiles }: Props = $props(); |
| 14 | +
|
| 15 | + let urlValue = $state(""); |
| 16 | + let loading = $state(false); |
| 17 | + let errorMsg = $state(""); |
| 18 | + let inputEl: HTMLInputElement | undefined = $state(); |
| 19 | +
|
| 20 | + async function focusInputSoon() { |
| 21 | + // Wait for modal and content to mount, then focus and select |
| 22 | + await tick(); |
| 23 | + await tick(); |
| 24 | + setTimeout(() => { |
| 25 | + inputEl?.focus(); |
| 26 | + inputEl?.select(); |
| 27 | + }, 0); |
| 28 | + } |
| 29 | +
|
| 30 | + $effect(() => { |
| 31 | + if (open) { |
| 32 | + // reset state when opening |
| 33 | + urlValue = ""; |
| 34 | + errorMsg = ""; |
| 35 | + void focusInputSoon(); |
| 36 | + } |
| 37 | + }); |
| 38 | +
|
| 39 | + function isHttpsUrl(url: string) { |
| 40 | + try { |
| 41 | + const u = new URL(url); |
| 42 | + return u.protocol === "https:"; |
| 43 | + } catch { |
| 44 | + return false; |
| 45 | + } |
| 46 | + } |
| 47 | +
|
| 48 | + function matchesAllowed(contentType: string, allowed: string[]): boolean { |
| 49 | + const ct = contentType.split(";")[0]?.trim().toLowerCase(); |
| 50 | + if (!ct) return false; |
| 51 | + const [ctType, ctSubtype] = ct.split("/"); |
| 52 | + for (const a of allowed) { |
| 53 | + const [aType, aSubtype] = a.toLowerCase().split("/"); |
| 54 | + const typeOk = aType === "*" || aType === ctType; |
| 55 | + const subOk = aSubtype === "*" || aSubtype === ctSubtype; |
| 56 | + if (typeOk && subOk) return true; |
| 57 | + } |
| 58 | + return false; |
| 59 | + } |
| 60 | +
|
| 61 | + function close() { |
| 62 | + open = false; |
| 63 | + onclose?.(); |
| 64 | + } |
| 65 | +
|
| 66 | + async function handleSubmit() { |
| 67 | + errorMsg = ""; |
| 68 | + const trimmed = urlValue.trim(); |
| 69 | + if (!isHttpsUrl(trimmed)) { |
| 70 | + errorMsg = "Enter a valid HTTPS URL."; |
| 71 | + return; |
| 72 | + } |
| 73 | + loading = true; |
| 74 | + try { |
| 75 | + // Use server proxy directly for one URL to validate size/types before creating File |
| 76 | + const params = new URLSearchParams({ url: trimmed }); |
| 77 | + if (acceptMimeTypes.length > 0) params.set("accept", acceptMimeTypes.join(",")); |
| 78 | + const proxyUrl = `${base}/api/fetch-url?${params}`; |
| 79 | + const res = await fetch(proxyUrl); |
| 80 | + if (!res.ok) { |
| 81 | + const txt = await res.text(); |
| 82 | + throw new Error(txt || `Failed to fetch (${res.status})`); |
| 83 | + } |
| 84 | + const blob = await res.blob(); |
| 85 | + // Optional client-side mime filter (same wildcard semantics as dropzone) |
| 86 | + if (acceptMimeTypes.length > 0 && blob.type && !matchesAllowed(blob.type, acceptMimeTypes)) { |
| 87 | + throw new Error("File type not allowed."); |
| 88 | + } |
| 89 | + const disp = res.headers.get("content-disposition"); |
| 90 | + let filename = "attachment"; |
| 91 | + const match = disp?.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); |
| 92 | + if (match && match[1]) filename = match[1].replace(/['"]/g, ""); |
| 93 | + else { |
| 94 | + try { |
| 95 | + const u = new URL(trimmed); |
| 96 | + const last = u.pathname.split("/").pop() || "attachment"; |
| 97 | + filename = decodeURIComponent(last); |
| 98 | + } catch {} |
| 99 | + } |
| 100 | + const file = new File([blob], filename, { type: blob.type || "application/octet-stream" }); |
| 101 | + onfiles?.([file]); |
| 102 | + close(); |
| 103 | + } catch (e) { |
| 104 | + errorMsg = e instanceof Error ? e.message : "Failed to fetch URL"; |
| 105 | + } finally { |
| 106 | + loading = false; |
| 107 | + } |
| 108 | + } |
| 109 | +</script> |
| 110 | + |
| 111 | +{#if open} |
| 112 | + <Modal onclose={close} width="w-[90dvh] md:w-[480px]"> |
| 113 | + {#snippet children()} |
| 114 | + <form |
| 115 | + class="flex w-full flex-col gap-5 p-6" |
| 116 | + onsubmit={(e) => { |
| 117 | + e.preventDefault(); |
| 118 | + handleSubmit(); |
| 119 | + }} |
| 120 | + > |
| 121 | + <div class="flex items-start justify-between"> |
| 122 | + <h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Add from URL</h2> |
| 123 | + <button type="button" class="group" onclick={close} aria-label="Close"> |
| 124 | + <svg |
| 125 | + xmlns="http://www.w3.org/2000/svg" |
| 126 | + viewBox="0 0 32 32" |
| 127 | + class="size-5 text-gray-700 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400" |
| 128 | + > |
| 129 | + <path |
| 130 | + 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" |
| 131 | + fill="currentColor" |
| 132 | + /> |
| 133 | + </svg> |
| 134 | + </button> |
| 135 | + </div> |
| 136 | + |
| 137 | + <div class="flex flex-col gap-2"> |
| 138 | + <label class="text-sm text-gray-600 dark:text-gray-400" for="fetch-url-input" |
| 139 | + >HTTPS URL</label |
| 140 | + > |
| 141 | + <input |
| 142 | + id="fetch-url-input" |
| 143 | + bind:this={inputEl} |
| 144 | + bind:value={urlValue} |
| 145 | + type="url" |
| 146 | + placeholder="https://example.com/file.txt" |
| 147 | + 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" |
| 148 | + aria-invalid={errorMsg ? "true" : "false"} |
| 149 | + onkeydown={(e) => { |
| 150 | + if (e.key === "Enter") { |
| 151 | + e.preventDefault(); |
| 152 | + handleSubmit(); |
| 153 | + } |
| 154 | + }} |
| 155 | + /> |
| 156 | + </div> |
| 157 | + |
| 158 | + {#if errorMsg} |
| 159 | + <p class="-mt-1 text-sm text-red-600 dark:text-red-400">{errorMsg}</p> |
| 160 | + {/if} |
| 161 | + <p class="-mt-2 text-xs text-gray-500 dark:text-gray-400">Only HTTPS. Max 10MB.</p> |
| 162 | + |
| 163 | + <div class="flex items-center justify-end gap-2"> |
| 164 | + <button |
| 165 | + type="button" |
| 166 | + 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" |
| 167 | + onclick={close} |
| 168 | + > |
| 169 | + Cancel |
| 170 | + </button> |
| 171 | + <button |
| 172 | + type="submit" |
| 173 | + 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" |
| 174 | + disabled={loading || urlValue.trim() === ""} |
| 175 | + > |
| 176 | + {#if loading}Fetching…{:else}Add{/if} |
| 177 | + </button> |
| 178 | + </div> |
| 179 | + </form> |
| 180 | + {/snippet} |
| 181 | + </Modal> |
| 182 | +{/if} |
| 183 | + |
| 184 | +<style lang="postcss"> |
| 185 | + :global(input) { |
| 186 | + font-family: inherit; |
| 187 | + } |
| 188 | + /* Uses app-level colors and rounded/blur styles via utility classes */ |
| 189 | + /* The Modal itself provides consistent container + scrollbar-custom styling */ |
| 190 | +</style> |
0 commit comments