mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-10 19:59:04 +02:00
feat: make my apps searchable and sortable
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import * as Pagination from '#lib/components/ui/pagination/index.ts';
|
||||
import * as Select from '#lib/components/ui/select/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import type { PaginationResponse } from '#lib/types/list-request.type.ts';
|
||||
import { cn } from '#lib/utils/style.ts';
|
||||
|
||||
let {
|
||||
pagination,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizes = [20, 50, 100],
|
||||
hideWhenSinglePage = false,
|
||||
class: className
|
||||
}: {
|
||||
pagination?: PaginationResponse;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
pageSizes?: number[];
|
||||
hideWhenSinglePage?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
// The controls are useless when every item already fits on a page of the smallest size
|
||||
const hidden = $derived(
|
||||
hideWhenSinglePage && (pagination?.totalItems ?? 0) <= Math.min(...pageSizes)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if !hidden}
|
||||
<div
|
||||
class={cn(
|
||||
'mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={pagination?.itemsPerPage.toString()}
|
||||
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||
>
|
||||
<Select.Trigger class="w-20" aria-label={m.items_per_page()}>
|
||||
{pagination?.itemsPerPage}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each pageSizes as size (size)}
|
||||
<Select.Item value={size.toString()}>{size}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={pagination?.totalItems || 0}
|
||||
perPage={pagination?.itemsPerPage}
|
||||
{onPageChange}
|
||||
page={pagination?.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-end">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={pagination?.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,9 +2,8 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page as currentPage } from '$app/state';
|
||||
import ActionsMenu from '#lib/components/actions-menu.svelte';
|
||||
import ListPagination from '#lib/components/list-pagination.svelte';
|
||||
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
|
||||
import * as Pagination from '#lib/components/ui/pagination/index.ts';
|
||||
import * as Select from '#lib/components/ui/select/index.ts';
|
||||
import * as Table from '#lib/components/ui/table/index.js';
|
||||
import Empty from '#lib/icons/empty.svelte';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
@@ -317,50 +316,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={items?.pagination.itemsPerPage.toString()}
|
||||
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||
>
|
||||
<Select.Trigger class="w-20">
|
||||
{items?.pagination.itemsPerPage}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availablePageSizes as size (size)}
|
||||
<Select.Item value={size.toString()}>{size}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={items?.pagination.totalItems || 0}
|
||||
perPage={items?.pagination.itemsPerPage}
|
||||
{onPageChange}
|
||||
page={items?.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-end">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
<ListPagination
|
||||
pagination={items?.pagination}
|
||||
pageSizes={availablePageSizes}
|
||||
{onPageChange}
|
||||
{onPageSizeChange}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
|
||||
import { Button } from '#lib/components/ui/button/index.ts';
|
||||
import ListPagination from '#lib/components/list-pagination.svelte';
|
||||
import { Button, buttonVariants } from '#lib/components/ui/button/index.ts';
|
||||
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Empty from '#lib/components/ui/empty/index.ts';
|
||||
import * as Pagination from '#lib/components/ui/pagination/index.ts';
|
||||
import * as InputGroup from '#lib/components/ui/input-group/index.js';
|
||||
import { Separator } from '#lib/components/ui/separator/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import OIDCService from '#lib/services/oidc-service.ts';
|
||||
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
||||
import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts';
|
||||
import type {
|
||||
AccessibleOidcClient,
|
||||
AuthorizedOidcClient,
|
||||
OidcClientMetaData
|
||||
} from '#lib/types/oidc.type.ts';
|
||||
import { debounced } from '#lib/utils/debounce-util.ts';
|
||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||
import { cn } from '#lib/utils/style.ts';
|
||||
import { ChevronDown, LayoutDashboard } from '@lucide/svelte';
|
||||
import { ArrowUpDown, ChevronDown, LayoutDashboard, Search, SearchX } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
import AuthorizedOidcClientCard from './authorized-oidc-client-card.svelte';
|
||||
import {
|
||||
getMyAppsPreferences,
|
||||
myAppsPageSizes,
|
||||
myAppsPreferences,
|
||||
myAppsSortOptions,
|
||||
type MyAppsSort
|
||||
} from './my-apps-preferences.svelte.ts';
|
||||
|
||||
let { data } = $props();
|
||||
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
|
||||
@@ -29,6 +41,8 @@
|
||||
data.authorizedClientRequestOptions
|
||||
);
|
||||
let showAllApps = $state(false);
|
||||
let searchValue = $state('');
|
||||
let sort: MyAppsSort = $state(getMyAppsPreferences().sort);
|
||||
const hiddenAuthorizedClients = $derived(
|
||||
authorizedClientsWithoutLaunchURL.data.map(({ client, lastUsedAt }) => ({
|
||||
...client,
|
||||
@@ -37,16 +51,70 @@
|
||||
);
|
||||
const oidcService = new OIDCService();
|
||||
|
||||
const animationDuration = $derived(
|
||||
$appConfigStore.disableAnimations || prefersReducedMotion.current ? 0 : 200
|
||||
);
|
||||
|
||||
const sortLabels: Record<MyAppsSort, () => string> = {
|
||||
recentlyUsed: m.recently_used,
|
||||
nameAsc: m.name_a_to_z,
|
||||
nameDesc: m.name_z_to_a
|
||||
};
|
||||
|
||||
// Only unfiltered results tell whether the user has any apps, so the toolbar never unmounts while a search is being typed or cleared
|
||||
let hasAnyApps = $state(
|
||||
data.clients.pagination.totalItems +
|
||||
data.authorizedClientsWithoutLaunchURL.pagination.totalItems >
|
||||
0
|
||||
);
|
||||
|
||||
async function refreshClients() {
|
||||
[clients, authorizedClientsWithoutLaunchURL] = await Promise.all([
|
||||
oidcService.listOwnAccessibleClients(requestOptions),
|
||||
oidcService.listOwnAuthorizedClients(authorizedClientRequestOptions)
|
||||
]);
|
||||
if (!requestOptions.search) {
|
||||
hasAnyApps =
|
||||
clients.pagination.totalItems + authorizedClientsWithoutLaunchURL.pagination.totalItems > 0;
|
||||
}
|
||||
if (authorizedClientsWithoutLaunchURL.pagination.totalItems === 0) {
|
||||
showAllApps = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Both grids share the search, sort and page size, so any change reloads both from their first page
|
||||
async function reloadFromFirstPage({
|
||||
search = searchValue,
|
||||
limit = getMyAppsPreferences().paginationLimit
|
||||
}: { search?: string; limit?: number } = {}) {
|
||||
for (const options of [requestOptions, authorizedClientRequestOptions]) {
|
||||
options.search = search || undefined;
|
||||
options.sort = { ...myAppsSortOptions[sort] };
|
||||
options.pagination = { page: 1, limit };
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshClients();
|
||||
// The search is applied together with its results so the empty state never describes stale results
|
||||
searchValue = search;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
const onSearch = debounced((search: string) => reloadFromFirstPage({ search }), 300);
|
||||
|
||||
async function onSortChange(value: string) {
|
||||
sort = value as MyAppsSort;
|
||||
myAppsPreferences.current.sort = sort;
|
||||
await reloadFromFirstPage();
|
||||
}
|
||||
|
||||
async function onPageSizeChange(size: number) {
|
||||
myAppsPreferences.current.paginationLimit = size;
|
||||
await reloadFromFirstPage({ limit: size });
|
||||
}
|
||||
|
||||
async function onPageChange(page: number) {
|
||||
requestOptions.pagination = { limit: clients.pagination.itemsPerPage, page };
|
||||
clients = await oidcService.listOwnAccessibleClients(requestOptions);
|
||||
@@ -94,23 +162,66 @@
|
||||
<title>{m.my_apps()}</title>
|
||||
</svelte:head>
|
||||
<div>
|
||||
<div>
|
||||
<h1 class="mb-5 flex items-center gap-2 text-2xl font-bold">
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold">
|
||||
<LayoutDashboard class="text-primary/80 size-6" />
|
||||
{m.my_apps()}
|
||||
</h1>
|
||||
{#if hasAnyApps}
|
||||
<div class="flex w-full items-center gap-2 sm:w-auto">
|
||||
<InputGroup.Root class="w-full sm:w-64">
|
||||
<InputGroup.Input
|
||||
value={searchValue}
|
||||
placeholder={m.search()}
|
||||
aria-label={m.search_apps()}
|
||||
type="search"
|
||||
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<InputGroup.Addon>
|
||||
<Search />
|
||||
</InputGroup.Addon>
|
||||
</InputGroup.Root>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({ variant: 'outline', size: 'icon', class: 'shrink-0' })}
|
||||
aria-label={m.sort_by()}
|
||||
title={m.sort_by()}
|
||||
>
|
||||
<ArrowUpDown />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-48">
|
||||
<DropdownMenu.Label>{m.sort_by()}</DropdownMenu.Label>
|
||||
<DropdownMenu.RadioGroup value={sort} onValueChange={onSortChange}>
|
||||
{#each Object.keys(myAppsSortOptions) as MyAppsSort[] as option (option)}
|
||||
<DropdownMenu.RadioItem value={option}
|
||||
>{sortLabels[option]()}</DropdownMenu.RadioItem
|
||||
>
|
||||
{/each}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if clients.data.length === 0 && !showAllApps}
|
||||
<Empty.Root class="mt-20">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<LayoutDashboard />
|
||||
</Empty.Media>
|
||||
<Empty.Title>{m.no_apps_available()}</Empty.Title>
|
||||
<Empty.Description>
|
||||
{m.contact_your_administrator_for_app_access()}
|
||||
</Empty.Description>
|
||||
{#if searchValue}
|
||||
<Empty.Media variant="icon">
|
||||
<SearchX />
|
||||
</Empty.Media>
|
||||
<Empty.Title>{m.no_apps_match_your_search()}</Empty.Title>
|
||||
<Empty.Description>{m.try_a_different_search_term()}</Empty.Description>
|
||||
{:else}
|
||||
<Empty.Media variant="icon">
|
||||
<LayoutDashboard />
|
||||
</Empty.Media>
|
||||
<Empty.Title>{m.no_apps_available()}</Empty.Title>
|
||||
<Empty.Description>
|
||||
{m.contact_your_administrator_for_app_access()}
|
||||
</Empty.Description>
|
||||
{/if}
|
||||
</Empty.Header>
|
||||
{#if authorizedClientsWithoutLaunchURL.pagination.totalItems > 0}
|
||||
<Empty.Content>
|
||||
@@ -122,108 +233,63 @@
|
||||
</Empty.Root>
|
||||
{:else}
|
||||
{#if clients.data.length > 0}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
|
||||
>
|
||||
{#each clients.data as client (client.id)}
|
||||
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
|
||||
{/each}
|
||||
<!-- Gap fix if two elements are present-->
|
||||
{#if clients.data.length === 2}
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="grid">
|
||||
{#key clients}
|
||||
<div
|
||||
class="col-start-1 row-start-1 grid gap-3 self-start"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
|
||||
transition:fade={{ duration: animationDuration }}
|
||||
>
|
||||
{#each clients.data as client (client.id)}
|
||||
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
|
||||
{/each}
|
||||
<!-- Gap fix if two elements are present-->
|
||||
{#if clients.data.length === 2}
|
||||
<div></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if clients.pagination.totalPages > 1}
|
||||
<div class="mt-5 flex items-center justify-center">
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={clients.pagination.totalItems}
|
||||
perPage={clients.pagination.itemsPerPage}
|
||||
{onPageChange}
|
||||
page={clients.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-center">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link
|
||||
{page}
|
||||
isActive={clients.pagination.currentPage === page.value}
|
||||
>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<ListPagination
|
||||
pagination={clients.pagination}
|
||||
pageSizes={myAppsPageSizes}
|
||||
{onPageChange}
|
||||
{onPageSizeChange}
|
||||
hideWhenSinglePage
|
||||
/>
|
||||
|
||||
{#if showAllApps}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<div transition:slide={{ duration: animationDuration }}>
|
||||
{#if clients.data.length > 0}
|
||||
<Separator class="my-8" />
|
||||
{/if}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
|
||||
>
|
||||
{#each hiddenAuthorizedClients as client (client.id)}
|
||||
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
|
||||
{/each}
|
||||
<!-- Gap fix if two elements are present-->
|
||||
{#if hiddenAuthorizedClients.length === 2}
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="grid">
|
||||
{#key authorizedClientsWithoutLaunchURL}
|
||||
<div
|
||||
class="col-start-1 row-start-1 grid gap-3 self-start"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
|
||||
transition:fade={{ duration: animationDuration }}
|
||||
>
|
||||
{#each hiddenAuthorizedClients as client (client.id)}
|
||||
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
|
||||
{/each}
|
||||
<!-- Gap fix if two elements are present-->
|
||||
{#if hiddenAuthorizedClients.length === 2}
|
||||
<div></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if authorizedClientsWithoutLaunchURL.pagination.totalPages > 1}
|
||||
<div class="mt-5 flex items-center justify-center">
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={authorizedClientsWithoutLaunchURL.pagination.totalItems}
|
||||
perPage={authorizedClientsWithoutLaunchURL.pagination.itemsPerPage}
|
||||
onPageChange={onAuthorizedClientPageChange}
|
||||
page={authorizedClientsWithoutLaunchURL.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-center">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link
|
||||
{page}
|
||||
isActive={authorizedClientsWithoutLaunchURL.pagination.currentPage ===
|
||||
page.value}
|
||||
>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<ListPagination
|
||||
pagination={authorizedClientsWithoutLaunchURL.pagination}
|
||||
pageSizes={myAppsPageSizes}
|
||||
onPageChange={onAuthorizedClientPageChange}
|
||||
{onPageSizeChange}
|
||||
hideWhenSinglePage
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import OIDCService from '#lib/services/oidc-service.ts';
|
||||
import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
|
||||
import type { PageLoad } from './$types';
|
||||
import { getMyAppsPreferences, myAppsSortOptions } from './my-apps-preferences.svelte.ts';
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
const oidcService = new OIDCService();
|
||||
const preferences = getMyAppsPreferences();
|
||||
|
||||
const appRequestOptions: ListRequestOptions = {
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20
|
||||
},
|
||||
sort: {
|
||||
column: 'lastUsedAt',
|
||||
direction: 'desc'
|
||||
limit: preferences.paginationLimit
|
||||
},
|
||||
sort: { ...myAppsSortOptions[preferences.sort] },
|
||||
filters: {
|
||||
hasLaunchURL: [true]
|
||||
}
|
||||
@@ -22,12 +21,9 @@ export const load: PageLoad = async () => {
|
||||
const authorizedClientRequestOptions: ListRequestOptions = {
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20
|
||||
},
|
||||
sort: {
|
||||
column: 'lastUsedAt',
|
||||
direction: 'desc'
|
||||
limit: preferences.paginationLimit
|
||||
},
|
||||
sort: { ...myAppsSortOptions[preferences.sort] },
|
||||
filters: {
|
||||
hasLaunchURL: [false]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SortRequest } from '#lib/types/list-request.type.ts';
|
||||
import { PersistedState } from 'runed';
|
||||
|
||||
export const myAppsSortOptions = {
|
||||
recentlyUsed: { column: 'lastUsedAt', direction: 'desc' },
|
||||
nameAsc: { column: 'name', direction: 'asc' },
|
||||
nameDesc: { column: 'name', direction: 'desc' }
|
||||
} satisfies Record<string, SortRequest>;
|
||||
|
||||
export type MyAppsSort = keyof typeof myAppsSortOptions;
|
||||
|
||||
export const myAppsPageSizes = [20, 50, 100];
|
||||
|
||||
type MyAppsPreferences = {
|
||||
sort: MyAppsSort;
|
||||
paginationLimit: number;
|
||||
};
|
||||
|
||||
const defaultPreferences: MyAppsPreferences = {
|
||||
sort: 'recentlyUsed',
|
||||
paginationLimit: myAppsPageSizes[0]
|
||||
};
|
||||
|
||||
export const myAppsPreferences = new PersistedState<MyAppsPreferences>(
|
||||
'my-apps-preferences',
|
||||
defaultPreferences
|
||||
);
|
||||
|
||||
// Stored values can be stale or edited by hand, so unknown ones fall back to the defaults
|
||||
export function getMyAppsPreferences(): MyAppsPreferences {
|
||||
const { sort, paginationLimit } = myAppsPreferences.current;
|
||||
return {
|
||||
sort: sort in myAppsSortOptions ? sort : defaultPreferences.sort,
|
||||
paginationLimit: myAppsPageSizes.includes(paginationLimit)
|
||||
? paginationLimit
|
||||
: defaultPreferences.paginationLimit
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user