|
1 | 1 | <script lang="ts"> |
| 2 | + import { browser } from '$app/environment'; |
2 | 3 | import { onMount, onDestroy } from 'svelte'; |
3 | 4 | import { page } from '$app/stores'; |
4 | 5 | import { goto } from '$app/navigation'; |
|
13 | 14 |
|
14 | 15 | // Known device names used as shorthand URL params (e.g. /models?cpu) |
15 | 16 | const KNOWN_DEVICES = ['cpu', 'gpu', 'npu']; |
| 17 | + const MODEL_QUERY_PARAM = 'model'; |
16 | 18 |
|
17 | 19 | // Debounce timer for search |
18 | 20 | let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null; |
|
28 | 30 | // Modal state |
29 | 31 | let selectedModel: GroupedFoundryModel | null = null; |
30 | 32 | let isModalOpen = false; |
| 33 | + let lastNonModelUrl = '/models'; |
| 34 | + let previousModalOpenState = false; |
| 35 | + let invalidModelAliasHandled: string | null = null; |
31 | 36 |
|
32 | 37 | // Filter state |
33 | 38 | let searchTerm = ''; |
|
49 | 54 |
|
50 | 55 | // Read filter state from URL search params |
51 | 56 | function readFiltersFromUrl() { |
52 | | - const params = $page.url.searchParams; |
| 57 | + const params = getUrlSearchParams(); |
53 | 58 |
|
54 | 59 | // Device filters: shorthand keys like ?cpu, ?gpu, ?npu |
55 | 60 | const devices: string[] = []; |
|
69 | 74 | sortOrder = (params.get('order') as 'asc' | 'desc') ?? 'desc'; |
70 | 75 | } |
71 | 76 |
|
72 | | - // Write current filter state to URL search params (replaceState, no navigation) |
73 | | - function updateUrlFromFilters() { |
74 | | - if (suppressUrlUpdate) return; |
| 77 | + function normalizeModelAlias(modelAlias: string): string { |
| 78 | + return modelAlias.trim().toLowerCase(); |
| 79 | + } |
| 80 | +
|
| 81 | + function getUrlSearchParams(): URLSearchParams { |
| 82 | + return browser ? $page.url.searchParams : new URLSearchParams(); |
| 83 | + } |
| 84 | +
|
| 85 | + function buildModelsUrl(params: URLSearchParams): string { |
| 86 | + const search = params.toString(); |
| 87 | + const cleanSearch = search.replace(/=(?=&|$)/g, ''); |
| 88 | + return cleanSearch ? `/models?${cleanSearch}` : '/models'; |
| 89 | + } |
75 | 90 |
|
| 91 | + function buildFilterSearchParams(): URLSearchParams { |
76 | 92 | const params = new URLSearchParams(); |
77 | 93 |
|
78 | | - // Device filters as shorthand keys |
79 | 94 | for (const device of selectedDevices) { |
80 | 95 | if (KNOWN_DEVICES.includes(device)) { |
81 | 96 | params.set(device, ''); |
|
88 | 103 | if (sortBy && sortBy !== 'lastModified') params.set('sort', sortBy); |
89 | 104 | if (sortOrder && sortOrder !== 'desc') params.set('order', sortOrder); |
90 | 105 |
|
91 | | - const search = params.toString(); |
92 | | - // Clean up empty-value params: "cpu=" -> "cpu" |
93 | | - const cleanSearch = search.replace(/=(?=&|$)/g, ''); |
94 | | - const newUrl = cleanSearch ? `/models?${cleanSearch}` : '/models'; |
| 106 | + return params; |
| 107 | + } |
| 108 | +
|
| 109 | + function getModelAliasFromUrl(): string { |
| 110 | + return normalizeModelAlias(getUrlSearchParams().get(MODEL_QUERY_PARAM) ?? ''); |
| 111 | + } |
| 112 | +
|
| 113 | + function getCurrentUrlWithoutModel(): string { |
| 114 | + const params = new URLSearchParams(getUrlSearchParams()); |
| 115 | + params.delete(MODEL_QUERY_PARAM); |
| 116 | + return buildModelsUrl(params); |
| 117 | + } |
| 118 | +
|
| 119 | + // Write current filter state to URL search params (replaceState, no navigation) |
| 120 | + function updateUrlFromFilters() { |
| 121 | + if (suppressUrlUpdate || getModelAliasFromUrl()) return; |
| 122 | +
|
| 123 | + const newUrl = buildModelsUrl(buildFilterSearchParams()); |
95 | 124 |
|
96 | 125 | goto(newUrl, { replaceState: true, noScroll: true, keepFocus: true }); |
97 | 126 | } |
98 | 127 |
|
| 128 | + function getModelByAlias(modelAlias: string): GroupedFoundryModel | null { |
| 129 | + const normalizedAlias = normalizeModelAlias(modelAlias); |
| 130 | + return ( |
| 131 | + allModels.find((model) => normalizeModelAlias(model.alias) === normalizedAlias) ?? null |
| 132 | + ); |
| 133 | + } |
| 134 | +
|
| 135 | + function syncSelectedModelFromUrl() { |
| 136 | + const modelAlias = getModelAliasFromUrl(); |
| 137 | +
|
| 138 | + if (!modelAlias) { |
| 139 | + invalidModelAliasHandled = null; |
| 140 | + selectedModel = null; |
| 141 | + isModalOpen = false; |
| 142 | + return; |
| 143 | + } |
| 144 | +
|
| 145 | + if (loading || error) return; |
| 146 | +
|
| 147 | + const matchedModel = getModelByAlias(modelAlias); |
| 148 | +
|
| 149 | + if (!matchedModel) { |
| 150 | + if (invalidModelAliasHandled !== modelAlias) { |
| 151 | + invalidModelAliasHandled = modelAlias; |
| 152 | + toast.error(`Model \"${modelAlias}\" is no longer available.`); |
| 153 | + goto(getCurrentUrlWithoutModel(), { |
| 154 | + replaceState: true, |
| 155 | + noScroll: true, |
| 156 | + keepFocus: true |
| 157 | + }); |
| 158 | + } |
| 159 | + return; |
| 160 | + } |
| 161 | +
|
| 162 | + invalidModelAliasHandled = null; |
| 163 | +
|
| 164 | + const currentUrlWithoutModel = getCurrentUrlWithoutModel(); |
| 165 | + if (currentUrlWithoutModel !== '/models' || lastNonModelUrl === '/models') { |
| 166 | + lastNonModelUrl = currentUrlWithoutModel; |
| 167 | + } |
| 168 | +
|
| 169 | + selectedModel = matchedModel; |
| 170 | + isModalOpen = true; |
| 171 | + } |
| 172 | +
|
| 173 | + function openModelDetails(model: GroupedFoundryModel) { |
| 174 | + selectedModel = model; |
| 175 | + isModalOpen = true; |
| 176 | + lastNonModelUrl = getCurrentUrlWithoutModel(); |
| 177 | +
|
| 178 | + const currentModelAlias = getModelAliasFromUrl(); |
| 179 | + const nextModelAlias = normalizeModelAlias(model.alias); |
| 180 | + if (currentModelAlias === nextModelAlias) return; |
| 181 | +
|
| 182 | + const params = new URLSearchParams(); |
| 183 | + params.set(MODEL_QUERY_PARAM, model.alias); |
| 184 | + goto(buildModelsUrl(params), { noScroll: true, keepFocus: true }); |
| 185 | + } |
| 186 | +
|
| 187 | + function closeModelDetails(restorePreviousUrl = true) { |
| 188 | + selectedModel = null; |
| 189 | + isModalOpen = false; |
| 190 | +
|
| 191 | + if (!getModelAliasFromUrl()) return; |
| 192 | +
|
| 193 | + const fallbackUrl = restorePreviousUrl ? lastNonModelUrl : getCurrentUrlWithoutModel(); |
| 194 | + goto(fallbackUrl || '/models', { |
| 195 | + replaceState: true, |
| 196 | + noScroll: true, |
| 197 | + keepFocus: true |
| 198 | + }); |
| 199 | + } |
| 200 | +
|
| 201 | + async function copyModelShareUrl(modelAlias: string) { |
| 202 | + try { |
| 203 | + const shareUrl = new URL('/models', window.location.origin); |
| 204 | + shareUrl.searchParams.set(MODEL_QUERY_PARAM, modelAlias); |
| 205 | + await navigator.clipboard.writeText(shareUrl.toString()); |
| 206 | + toast.success('Model link copied to clipboard'); |
| 207 | + } catch (err) { |
| 208 | + toast.error('Failed to copy link'); |
| 209 | + } |
| 210 | + } |
| 211 | +
|
99 | 212 | // Fetch all models from API |
100 | 213 | async function fetchAllModels() { |
101 | 214 | loading = true; |
|
246 | 359 | } |
247 | 360 | } |
248 | 361 |
|
249 | | - function openModelDetails(model: GroupedFoundryModel) { |
250 | | - selectedModel = model; |
251 | | - isModalOpen = true; |
252 | | - } |
253 | | -
|
254 | 362 | // Reactive statements |
255 | 363 | $: { |
256 | 364 | if (searchDebounceTimer) { |
|
289 | 397 | } |
290 | 398 |
|
291 | 399 | // Sync filter state to URL whenever filters change (after initialization) |
292 | | - $: if (filtersInitialized) { |
| 400 | + $: if (browser && filtersInitialized) { |
293 | 401 | // Track all filter values to trigger reactivity |
294 | 402 | selectedDevices; |
295 | 403 | selectedFamily; |
|
301 | 409 | updateUrlFromFilters(); |
302 | 410 | } |
303 | 411 |
|
| 412 | + $: if (browser && filtersInitialized) { |
| 413 | + $page.url.searchParams.get(MODEL_QUERY_PARAM); |
| 414 | + allModels; |
| 415 | + loading; |
| 416 | + error; |
| 417 | +
|
| 418 | + syncSelectedModelFromUrl(); |
| 419 | + } |
| 420 | +
|
| 421 | + $: if (browser) { |
| 422 | + const modelAlias = getModelAliasFromUrl(); |
| 423 | + if (previousModalOpenState && !isModalOpen && modelAlias) { |
| 424 | + closeModelDetails(true); |
| 425 | + } |
| 426 | + previousModalOpenState = isModalOpen; |
| 427 | + } |
| 428 | +
|
304 | 429 | onMount(() => { |
305 | 430 | // Read initial filter state from URL before fetching |
306 | 431 | suppressUrlUpdate = true; |
|
409 | 534 | {copiedModelId} |
410 | 535 | onCopyModelId={copyModelId} |
411 | 536 | onCopyCommand={copyRunCommand} |
| 537 | + onCopyShareUrl={copyModelShareUrl} |
412 | 538 | /> |
413 | 539 |
|
414 | 540 | <Footer /> |
|
0 commit comments