Skip to content

Commit 2e772f7

Browse files
authored
Merge pull request #971 from mnfst/pagination
feat: add pagination to Messages and Model Prices
2 parents 413f1e0 + 32c6de1 commit 2e772f7

File tree

13 files changed

+1371
-393
lines changed

13 files changed

+1371
-393
lines changed

.changeset/loud-pillows-melt.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
---
2+
"manifest": minor
23
---
4+
5+
Add pagination to Messages and Model Prices pages with shared Pagination component, cursor-based and client-side pagination primitives, filter empty state improvements, and unified provider icon system in filter bar

packages/frontend/src/components/ModelPricesFilterBar.tsx

Lines changed: 92 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { createSignal, createMemo, For, Show, onCleanup, type Component } from "solid-js";
1+
import { createSignal, createMemo, For, Show, onCleanup, type Component } from 'solid-js';
2+
import { resolveProviderId } from '../services/routing-utils.js';
3+
import { providerIcon } from './ProviderIcon.jsx';
24

35
interface ModelPricesFilterBarProps {
46
allModels: string[];
@@ -15,66 +17,53 @@ interface ModelPricesFilterBarProps {
1517
}
1618

1719
interface Suggestion {
18-
type: "Provider" | "Model";
20+
type: 'Provider' | 'Model';
1921
value: string;
2022
}
2123

2224
interface Tag {
23-
type: "Provider" | "Model";
25+
type: 'Provider' | 'Model';
2426
value: string;
2527
}
2628

27-
/** Maps provider display names to icon filenames in /icons/providers/ */
28-
const providerIconMap: Record<string, string> = {
29-
OpenAI: "openai",
30-
Anthropic: "anthropic",
31-
Google: "google",
32-
DeepSeek: "deepseek",
33-
Mistral: "mistral",
34-
Meta: "meta",
35-
Amazon: "amazon",
36-
Alibaba: "alibaba",
37-
Moonshot: "moonshot",
38-
Zhipu: "zhipu",
39-
Cohere: "cohere",
40-
xAI: "xai",
41-
};
42-
43-
/** Providers whose icons are monochrome (dark fill) and need inversion in dark mode */
44-
const monoProviders = new Set(["OpenAI", "Anthropic", "Moonshot", "xAI"]);
45-
46-
const getProviderIconSrc = (provider: string): string | null => {
47-
const key = providerIconMap[provider];
48-
return key ? `/icons/providers/${key}.svg` : null;
49-
};
50-
51-
const ProviderIcon: Component<{ provider: string; size?: number }> = (props) => {
52-
const src = () => getProviderIconSrc(props.provider);
29+
const FilterProviderIcon: Component<{ provider: string; size?: number }> = (props) => {
5330
const size = () => props.size ?? 16;
54-
const isMono = () => monoProviders.has(props.provider);
31+
const id = () => resolveProviderId(props.provider);
32+
const icon = () => {
33+
const pid = id();
34+
return pid ? providerIcon(pid, size()) : null;
35+
};
5536

5637
return (
57-
<Show when={src()} fallback={
58-
<svg class="model-filter__provider-icon" width={size()} height={size()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
59-
<rect x="3" y="3" width="18" height="18" rx="3" />
60-
<circle cx="12" cy="12" r="3" />
61-
</svg>
62-
}>
63-
<img
64-
class="model-filter__provider-icon"
65-
classList={{ "model-filter__provider-icon--mono": isMono() }}
66-
src={src()!}
67-
alt=""
68-
width={size()}
69-
height={size()}
70-
aria-hidden="true"
71-
/>
38+
<Show
39+
when={icon()}
40+
fallback={
41+
<svg
42+
class="model-filter__provider-icon"
43+
width={size()}
44+
height={size()}
45+
viewBox="0 0 24 24"
46+
fill="none"
47+
stroke="currentColor"
48+
stroke-width="2"
49+
stroke-linecap="round"
50+
stroke-linejoin="round"
51+
aria-hidden="true"
52+
>
53+
<rect x="3" y="3" width="18" height="18" rx="3" />
54+
<circle cx="12" cy="12" r="3" />
55+
</svg>
56+
}
57+
>
58+
<span class="model-filter__provider-icon" aria-hidden="true">
59+
{icon()}
60+
</span>
7261
</Show>
7362
);
7463
};
7564

7665
const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
77-
const [query, setQuery] = createSignal("");
66+
const [query, setQuery] = createSignal('');
7867
const [dropdownOpen, setDropdownOpen] = createSignal(false);
7968
const [highlightIndex, setHighlightIndex] = createSignal(-1);
8069
let comboboxRef: HTMLDivElement | undefined;
@@ -85,10 +74,10 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
8574
const activeTags = createMemo<Tag[]>(() => {
8675
const tags: Tag[] = [];
8776
for (const p of props.selectedProviders) {
88-
tags.push({ type: "Provider", value: p });
77+
tags.push({ type: 'Provider', value: p });
8978
}
9079
for (const m of props.selectedModels) {
91-
tags.push({ type: "Model", value: m });
80+
tags.push({ type: 'Model', value: m });
9281
}
9382
return tags;
9483
});
@@ -112,28 +101,28 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
112101
const flatSuggestions = createMemo<Suggestion[]>(() => {
113102
const suggestions: Suggestion[] = [];
114103
for (const p of matchingProviders()) {
115-
suggestions.push({ type: "Provider", value: p });
104+
suggestions.push({ type: 'Provider', value: p });
116105
}
117106
for (const m of matchingModels()) {
118-
suggestions.push({ type: "Model", value: m });
107+
suggestions.push({ type: 'Model', value: m });
119108
}
120109
return suggestions;
121110
});
122111

123112
const selectSuggestion = (suggestion: Suggestion) => {
124-
if (suggestion.type === "Provider") {
113+
if (suggestion.type === 'Provider') {
125114
props.onAddProvider(suggestion.value);
126115
} else {
127116
props.onAddModel(suggestion.value);
128117
}
129-
setQuery("");
118+
setQuery('');
130119
setDropdownOpen(false);
131120
setHighlightIndex(-1);
132121
inputRef?.focus();
133122
};
134123

135124
const removeTag = (tag: Tag) => {
136-
if (tag.type === "Provider") {
125+
if (tag.type === 'Provider') {
137126
props.onRemoveProvider(tag.value);
138127
} else {
139128
props.onRemoveModel(tag.value);
@@ -150,25 +139,25 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
150139
const handleKeyDown = (e: KeyboardEvent) => {
151140
const suggestions = flatSuggestions();
152141

153-
if (e.key === "ArrowDown") {
142+
if (e.key === 'ArrowDown') {
154143
e.preventDefault();
155144
if (!dropdownOpen() && query().trim().length >= 2) {
156145
setDropdownOpen(true);
157146
}
158147
setHighlightIndex((i) => Math.min(i + 1, suggestions.length - 1));
159-
} else if (e.key === "ArrowUp") {
148+
} else if (e.key === 'ArrowUp') {
160149
e.preventDefault();
161150
setHighlightIndex((i) => Math.max(i - 1, 0));
162-
} else if (e.key === "Enter") {
151+
} else if (e.key === 'Enter') {
163152
e.preventDefault();
164153
const idx = highlightIndex();
165154
if (idx >= 0 && idx < suggestions.length) {
166155
selectSuggestion(suggestions[idx]!);
167156
}
168-
} else if (e.key === "Escape") {
157+
} else if (e.key === 'Escape') {
169158
setDropdownOpen(false);
170159
setHighlightIndex(-1);
171-
} else if (e.key === "Backspace" && query() === "") {
160+
} else if (e.key === 'Backspace' && query() === '') {
172161
const tags = activeTags();
173162
if (tags.length > 0) {
174163
removeTag(tags[tags.length - 1]!);
@@ -183,10 +172,10 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
183172
}
184173
};
185174

186-
if (typeof document !== "undefined") {
187-
document.addEventListener("click", handleClickOutside);
175+
if (typeof document !== 'undefined') {
176+
document.addEventListener('click', handleClickOutside);
188177
onCleanup(() => {
189-
document.removeEventListener("click", handleClickOutside);
178+
document.removeEventListener('click', handleClickOutside);
190179
});
191180
}
192181

@@ -231,19 +220,24 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
231220
<div class="model-filter__dropdown-label">Providers</div>
232221
<For each={matchingProviders()}>
233222
{(provider) => {
234-
const idx = () => flatSuggestions().findIndex((s) => s.type === "Provider" && s.value === provider);
223+
const idx = () =>
224+
flatSuggestions().findIndex(
225+
(s) => s.type === 'Provider' && s.value === provider,
226+
);
235227
return (
236228
<button
237229
class="model-filter__dropdown-item"
238-
classList={{ "model-filter__dropdown-item--highlighted": highlightIndex() === idx() }}
239-
onClick={() => selectSuggestion({ type: "Provider", value: provider })}
230+
classList={{
231+
'model-filter__dropdown-item--highlighted': highlightIndex() === idx(),
232+
}}
233+
onClick={() => selectSuggestion({ type: 'Provider', value: provider })}
240234
onMouseEnter={() => setHighlightIndex(idx())}
241235
type="button"
242236
role="option"
243237
aria-selected={highlightIndex() === idx()}
244238
>
245239
<span class="model-filter__dropdown-item-name">
246-
<ProviderIcon provider={provider} size={16} />
240+
<FilterProviderIcon provider={provider} size={16} />
247241
{provider}
248242
</span>
249243
<span class="model-filter__dropdown-item-type">Provider</span>
@@ -258,12 +252,15 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
258252
<div class="model-filter__dropdown-label">Models</div>
259253
<For each={matchingModels()}>
260254
{(model) => {
261-
const idx = () => flatSuggestions().findIndex((s) => s.type === "Model" && s.value === model);
255+
const idx = () =>
256+
flatSuggestions().findIndex((s) => s.type === 'Model' && s.value === model);
262257
return (
263258
<button
264259
class="model-filter__dropdown-item"
265-
classList={{ "model-filter__dropdown-item--highlighted": highlightIndex() === idx() }}
266-
onClick={() => selectSuggestion({ type: "Model", value: model })}
260+
classList={{
261+
'model-filter__dropdown-item--highlighted': highlightIndex() === idx(),
262+
}}
263+
onClick={() => selectSuggestion({ type: 'Model', value: model })}
267264
onMouseEnter={() => setHighlightIndex(idx())}
268265
type="button"
269266
role="option"
@@ -281,13 +278,17 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
281278
</Show>
282279
</div>
283280
<div class="model-filter__summary">
284-
<Show when={hasActiveFilters()} fallback={
285-
<span>{props.totalCount} models</span>
286-
}>
287-
<span>{props.filteredCount} of {props.totalCount} models</span>
281+
<Show when={hasActiveFilters()} fallback={<span>{props.totalCount} models</span>}>
282+
<span>
283+
{props.filteredCount} of {props.totalCount} models
284+
</span>
288285
<button
289286
class="model-filter__clear-all"
290-
onClick={() => { props.onClearFilters(); setQuery(""); setDropdownOpen(false); }}
287+
onClick={() => {
288+
props.onClearFilters();
289+
setQuery('');
290+
setDropdownOpen(false);
291+
}}
291292
type="button"
292293
>
293294
Clear filters
@@ -299,11 +300,14 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
299300
<div class="model-filter__tags">
300301
<For each={activeTags()}>
301302
{(tag) => (
302-
<span class="model-filter__tag" classList={{ "model-filter__tag--provider": tag.type === "Provider" }}>
303-
<Show when={tag.type === "Provider"}>
304-
<ProviderIcon provider={tag.value} size={16} />
303+
<span
304+
class="model-filter__tag"
305+
classList={{ 'model-filter__tag--provider': tag.type === 'Provider' }}
306+
>
307+
<Show when={tag.type === 'Provider'}>
308+
<FilterProviderIcon provider={tag.value} size={16} />
305309
</Show>
306-
<Show when={tag.type === "Model"}>
310+
<Show when={tag.type === 'Model'}>
307311
<span class="model-filter__tag-type">Model:</span>
308312
</Show>
309313
<span class="model-filter__tag-value">{tag.value}</span>
@@ -313,7 +317,17 @@ const ModelPricesFilterBar: Component<ModelPricesFilterBarProps> = (props) => {
313317
type="button"
314318
aria-label={`Remove ${tag.type} ${tag.value}`}
315319
>
316-
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
320+
<svg
321+
width="10"
322+
height="10"
323+
viewBox="0 0 24 24"
324+
fill="none"
325+
stroke="currentColor"
326+
stroke-width="2.5"
327+
stroke-linecap="round"
328+
stroke-linejoin="round"
329+
aria-hidden="true"
330+
>
317331
<path d="M18 6 6 18" />
318332
<path d="m6 6 12 12" />
319333
</svg>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Show, type Component, type Accessor } from 'solid-js';
2+
3+
export interface PaginationProps {
4+
currentPage: Accessor<number>;
5+
totalItems: Accessor<number>;
6+
pageSize: number;
7+
hasNextPage: Accessor<boolean>;
8+
isLoading?: Accessor<boolean>;
9+
onPrevious: () => void;
10+
onNext: () => void;
11+
}
12+
13+
const Pagination: Component<PaginationProps> = (props) => {
14+
const start = () => (props.currentPage() - 1) * props.pageSize + 1;
15+
const end = () => Math.min(props.currentPage() * props.pageSize, props.totalItems());
16+
const loading = () => props.isLoading?.() ?? false;
17+
18+
return (
19+
<Show when={props.totalItems() > props.pageSize}>
20+
<nav class="pagination" role="navigation" aria-label="Pagination">
21+
<span class="pagination__summary">
22+
Showing {start()}&ndash;{end()} of {props.totalItems()}
23+
</span>
24+
<div class="pagination__controls">
25+
<button
26+
class="btn btn--outline btn--sm pagination__btn"
27+
disabled={props.currentPage() <= 1 || loading()}
28+
onClick={props.onPrevious}
29+
>
30+
Previous
31+
</button>
32+
<button
33+
class="btn btn--outline btn--sm pagination__btn"
34+
disabled={!props.hasNextPage() || loading()}
35+
onClick={props.onNext}
36+
>
37+
Next
38+
</button>
39+
</div>
40+
</nav>
41+
</Show>
42+
);
43+
};
44+
45+
export default Pagination;

0 commit comments

Comments
 (0)