Skip to content

Commit a103fac

Browse files
authored
Add shareable URLs for model popouts (#488)
## Summary Add direct shareable URLs for model details popouts on the models page. ## Changes - drive model popout state from the `model` query param on `/models` - restore the prior models page URL when closing a popout opened from filtered results - clear invalid model aliases with a toast and remove the bad query param - add a canonical `Copy Share Link` action for the model details modal - reserve header space so the share action does not crowd the modal close button ## Validation - tested locally in the running dev server - verified editor diagnostics reported no errors in the touched files
1 parent 17bc6c4 commit a103fac

2 files changed

Lines changed: 161 additions & 23 deletions

File tree

www/src/routes/models/+page.svelte

Lines changed: 141 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import { browser } from '$app/environment';
23
import { onMount, onDestroy } from 'svelte';
34
import { page } from '$app/stores';
45
import { goto } from '$app/navigation';
@@ -13,6 +14,7 @@
1314
1415
// Known device names used as shorthand URL params (e.g. /models?cpu)
1516
const KNOWN_DEVICES = ['cpu', 'gpu', 'npu'];
17+
const MODEL_QUERY_PARAM = 'model';
1618
1719
// Debounce timer for search
1820
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -28,6 +30,9 @@
2830
// Modal state
2931
let selectedModel: GroupedFoundryModel | null = null;
3032
let isModalOpen = false;
33+
let lastNonModelUrl = '/models';
34+
let previousModalOpenState = false;
35+
let invalidModelAliasHandled: string | null = null;
3136
3237
// Filter state
3338
let searchTerm = '';
@@ -49,7 +54,7 @@
4954
5055
// Read filter state from URL search params
5156
function readFiltersFromUrl() {
52-
const params = $page.url.searchParams;
57+
const params = getUrlSearchParams();
5358
5459
// Device filters: shorthand keys like ?cpu, ?gpu, ?npu
5560
const devices: string[] = [];
@@ -69,13 +74,23 @@
6974
sortOrder = (params.get('order') as 'asc' | 'desc') ?? 'desc';
7075
}
7176
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+
}
7590
91+
function buildFilterSearchParams(): URLSearchParams {
7692
const params = new URLSearchParams();
7793
78-
// Device filters as shorthand keys
7994
for (const device of selectedDevices) {
8095
if (KNOWN_DEVICES.includes(device)) {
8196
params.set(device, '');
@@ -88,14 +103,112 @@
88103
if (sortBy && sortBy !== 'lastModified') params.set('sort', sortBy);
89104
if (sortOrder && sortOrder !== 'desc') params.set('order', sortOrder);
90105
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());
95124
96125
goto(newUrl, { replaceState: true, noScroll: true, keepFocus: true });
97126
}
98127
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+
99212
// Fetch all models from API
100213
async function fetchAllModels() {
101214
loading = true;
@@ -246,11 +359,6 @@
246359
}
247360
}
248361
249-
function openModelDetails(model: GroupedFoundryModel) {
250-
selectedModel = model;
251-
isModalOpen = true;
252-
}
253-
254362
// Reactive statements
255363
$: {
256364
if (searchDebounceTimer) {
@@ -289,7 +397,7 @@
289397
}
290398
291399
// Sync filter state to URL whenever filters change (after initialization)
292-
$: if (filtersInitialized) {
400+
$: if (browser && filtersInitialized) {
293401
// Track all filter values to trigger reactivity
294402
selectedDevices;
295403
selectedFamily;
@@ -301,6 +409,23 @@
301409
updateUrlFromFilters();
302410
}
303411
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+
304429
onMount(() => {
305430
// Read initial filter state from URL before fetching
306431
suppressUrlUpdate = true;
@@ -409,6 +534,7 @@
409534
{copiedModelId}
410535
onCopyModelId={copyModelId}
411536
onCopyCommand={copyRunCommand}
537+
onCopyShareUrl={copyModelShareUrl}
412538
/>
413539

414540
<Footer />

www/src/routes/models/components/ModelDetailsModal.svelte

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
export let copiedModelId: string | null = null;
1212
export let onCopyModelId: (modelId: string) => void;
1313
export let onCopyCommand: (modelId: string) => void;
14+
export let onCopyShareUrl: (modelAlias: string) => void;
1415
1516
// Tags to hide from the details view (internal/system metadata)
1617
const HIDDEN_TAG_SUBSTRINGS = [
@@ -208,14 +209,25 @@
208209
<Dialog.Root bind:open={isOpen}>
209210
<Dialog.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
210211
{#if model}
211-
<Dialog.Header>
212-
<Dialog.Title class="flex items-center gap-3 text-2xl font-bold">
213-
<span>{model.displayName}</span>
214-
<Badge variant="secondary" class="text-xs">v{model.latestVersion}</Badge>
215-
</Dialog.Title>
216-
<Dialog.Description class="text-muted-foreground text-sm">
217-
{model.publisher}
218-
</Dialog.Description>
212+
<Dialog.Header class="gap-3 pr-10 sm:flex-row sm:items-start sm:justify-between sm:pr-12">
213+
<div class="space-y-1.5">
214+
<Dialog.Title class="flex items-center gap-3 text-2xl font-bold">
215+
<span>{model.displayName}</span>
216+
<Badge variant="secondary" class="text-xs">v{model.latestVersion}</Badge>
217+
</Dialog.Title>
218+
<Dialog.Description class="text-muted-foreground text-sm">
219+
{model.publisher}
220+
</Dialog.Description>
221+
</div>
222+
<Button
223+
variant="outline"
224+
size="sm"
225+
onclick={() => onCopyShareUrl(model.alias)}
226+
class="gap-2 self-start"
227+
>
228+
<Copy class="size-4" />
229+
Copy Share Link
230+
</Button>
219231
</Dialog.Header>
220232

221233
<div class="mt-6 space-y-6">

0 commit comments

Comments
 (0)