feat: Added working search filters

This commit is contained in:
LynithDev 2024-09-06 21:33:21 +02:00
parent 5012f2c3a6
commit 3025502d82
14 changed files with 172 additions and 131 deletions

View file

@ -55,6 +55,7 @@ pub async fn get_provider_package_version(
pub struct ProviderSearchQuery { pub struct ProviderSearchQuery {
query: Option<String>, query: Option<String>,
limit: Option<u8>, limit: Option<u8>,
offset: Option<u32>,
game_versions: Option<Vec<String>>, game_versions: Option<Vec<String>>,
categories: Option<Vec<String>>, categories: Option<Vec<String>>,
loaders: Option<Vec<Loader>>, loaders: Option<Vec<Loader>>,
@ -69,6 +70,7 @@ pub async fn search_provider_packages(provider: Providers, query: ProviderSearch
.search( .search(
query.query, query.query,
query.limit, query.limit,
query.offset,
query.game_versions, query.game_versions,
query.categories, query.categories,
query.loaders, query.loaders,

View file

@ -1,4 +1,4 @@
import type { ProviderSearchResults, Providers, SearchResult } from '@onelauncher/client/bindings'; import type { Providers, SearchResult } from '@onelauncher/client/bindings';
import { ChevronDownIcon, Download01Icon, HeartIcon } from '@untitled-theme/icons-solid'; import { ChevronDownIcon, Download01Icon, HeartIcon } from '@untitled-theme/icons-solid';
import { For, Match, Show, Switch, createSignal } from 'solid-js'; import { For, Match, Show, Switch, createSignal } from 'solid-js';
import Button from '../base/Button'; import Button from '../base/Button';
@ -6,11 +6,13 @@ import useBrowser from '~ui/hooks/useBrowser';
import useSettings from '~ui/hooks/useSettings'; import useSettings from '~ui/hooks/useSettings';
import { abbreviateNumber } from '~utils'; import { abbreviateNumber } from '~utils';
type SearchResultsContainerProps = ProviderSearchResults & { interface SearchResultsContainerProps {
provider: Providers;
results: SearchResult[];
header: string; header: string;
category: string; category: string;
collapsable?: boolean; collapsable?: boolean;
}; }
function SearchResultsContainer(props: SearchResultsContainerProps) { function SearchResultsContainer(props: SearchResultsContainerProps) {
const { settings } = useSettings(); const { settings } = useSettings();

View file

@ -1,5 +1,5 @@
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { type Accessor, type Context, For, type ParentProps, type Setter, Show, createContext, createEffect, createSignal, onMount, untrack, useContext } from 'solid-js'; import { type Accessor, type Context, For, type ParentProps, type Setter, Show, createContext, createEffect, createSignal, on, onMount, untrack, useContext } from 'solid-js';
import type { Cluster, ManagedPackage, PackageType, ProviderSearchQuery, ProviderSearchResults, Providers } from '@onelauncher/client/bindings'; import type { Cluster, ManagedPackage, PackageType, ProviderSearchQuery, ProviderSearchResults, Providers } from '@onelauncher/client/bindings';
import useCommand, { tryResult } from './useCommand'; import useCommand, { tryResult } from './useCommand';
import { useRecentCluster } from './useCluster'; import { useRecentCluster } from './useCluster';
@ -46,6 +46,7 @@ export function BrowserProvider(props: ParentProps) {
const [searchOptions, setSearchOptions] = createSignal<ProviderSearchQuery>({ const [searchOptions, setSearchOptions] = createSignal<ProviderSearchQuery>({
query: '', query: '',
limit: 20, limit: 20,
offset: 0,
categories: null, categories: null,
game_versions: null, game_versions: null,
loaders: null, loaders: null,
@ -119,6 +120,21 @@ export function BrowserProvider(props: ParentProps) {
setCluster(recentCluster()); setCluster(recentCluster());
}); });
createEffect(on(cluster, (cluster) => {
if (cluster !== undefined)
setSearchOptions((prev) => {
const opts: ProviderSearchQuery = {
...prev,
game_versions: [cluster.meta.mc_version],
};
if (cluster.meta.loader !== undefined)
opts.loaders = [cluster.meta.loader];
return opts;
});
}));
return ( return (
<BrowserContext.Provider value={controller}> <BrowserContext.Provider value={controller}>
{props.children} {props.children}

View file

@ -2,26 +2,34 @@ import { ChevronLeftIcon, ChevronRightIcon } from '@untitled-theme/icons-solid';
import { For, Show, createSignal } from 'solid-js'; import { For, Show, createSignal } from 'solid-js';
import Button from '~ui/components/base/Button'; import Button from '~ui/components/base/Button';
interface PaginationOptions { export interface PaginationOptions {
itemsCount: () => number; itemsCount: () => number;
itemsPerPage: () => number; itemsPerPage: () => number;
initialPage?: () => number; initialPage?: () => number;
}; };
function usePagination(options: PaginationOptions) { function usePagination(initialOptions: PaginationOptions) {
const [page, setPage] = createSignal(Math.max(1, options.initialPage?.() || 1)); const [options, setOptions] = createSignal(initialOptions);
// eslint-disable-next-line solid/reactivity -- -
const [page, setPage] = createSignal(Math.max(1, options().initialPage?.() || 1));
const next = () => setPage(page => page + 1); const next = () => setPage(page => page + 1);
const prev = () => setPage(page => page - 1); const prev = () => setPage(page => page - 1);
const totalPages = () => Math.ceil(options.itemsCount() / options.itemsPerPage()); const totalPages = () => Math.ceil(options().itemsCount() / options().itemsPerPage());
const hasNext = () => page() < totalPages(); const hasNext = () => page() < totalPages();
const hasPrev = () => page() > 1; const hasPrev = () => page() > 1;
const offset = () => (page() - 1) * options.itemsPerPage(); const offset = () => (page() - 1) * options().itemsPerPage();
// Exported
function reset(options: PaginationOptions) {
setOptions(options);
setPage(1);
}
// Navigation
function getVisiblePageNumbers(page: number) { function getVisiblePageNumbers(page: number) {
const pages: number[] = []; const pages: number[] = [];
@ -97,6 +105,7 @@ function usePagination(options: PaginationOptions) {
hasPrev, hasPrev,
totalPages, totalPages,
offset, offset,
reset,
Navigation, Navigation,
}; };
} }

View file

@ -1,23 +0,0 @@
import { useSearchParams } from '@solidjs/router';
import ScrollableContainer from '~ui/components/ScrollableContainer';
function BrowserCategory() {
const [params] = useSearchParams<{
category: string;
}>();
return (
<div>
<h1>
Category =
{' '}
{params.category}
</h1>
<ScrollableContainer>
<span>test</span>
</ScrollableContainer>
</div>
);
}
export default BrowserCategory;

View file

@ -1,5 +1,5 @@
import { ChevronRightIcon } from '@untitled-theme/icons-solid'; import { ChevronRightIcon } from '@untitled-theme/icons-solid';
import { Show } from 'solid-js'; import { Show, onMount } from 'solid-js';
import type { ManagedPackage } from '@onelauncher/client/bindings'; import type { ManagedPackage } from '@onelauncher/client/bindings';
import { BrowserContent } from './BrowserRoot'; import { BrowserContent } from './BrowserRoot';
import useBrowser from '~ui/hooks/useBrowser'; import useBrowser from '~ui/hooks/useBrowser';
@ -10,6 +10,13 @@ import SearchResultsContainer from '~ui/components/content/SearchResults';
function BrowserMain() { function BrowserMain() {
const browser = useBrowser(); const browser = useBrowser();
onMount(() => {
browser.setSearchQuery(prev => ({
...prev,
categories: [],
}));
});
return ( return (
<BrowserContent> <BrowserContent>
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">

View file

@ -145,6 +145,9 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
{' '} {' '}
<span class="text-fg-primary">{props.package.provider}</span> <span class="text-fg-primary">{props.package.provider}</span>
</p> </p>
<Show when={props.package.is_archived === true}>
<p class="w-fit rounded-full bg-code-warn/10 px-2 py-1 text-xs text-code-warn">Archived</p>
</Show>
</div> </div>
<p class="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{props.package.description}</p> <p class="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{props.package.description}</p>

View file

@ -1,9 +1,7 @@
import { Route } from '@solidjs/router'; import { Route, useLocation } from '@solidjs/router';
import { For, type JSX, type ParentProps } from 'solid-js'; import { For, type JSX, type ParentProps } from 'solid-js';
import { SearchMdIcon, Settings01Icon } from '@untitled-theme/icons-solid'; import { SearchMdIcon, Settings01Icon } from '@untitled-theme/icons-solid';
import type { PackageType } from '@onelauncher/client/bindings';
import BrowserMain from './BrowserMain'; import BrowserMain from './BrowserMain';
import BrowserCategory from './BrowserCategory';
import BrowserPackage from './BrowserPackage'; import BrowserPackage from './BrowserPackage';
import BrowserSearch from './BrowserSearch'; import BrowserSearch from './BrowserSearch';
import Button from '~ui/components/base/Button'; import Button from '~ui/components/base/Button';
@ -15,7 +13,6 @@ function BrowserRoutes() {
return ( return (
<> <>
<Route path="/" component={BrowserMain} /> <Route path="/" component={BrowserMain} />
<Route path="/category" component={BrowserCategory} />
<Route path="/package" component={BrowserPackage} children={<BrowserPackage.Routes />} /> <Route path="/package" component={BrowserPackage} children={<BrowserPackage.Routes />} />
<Route path="/search" component={BrowserSearch} /> <Route path="/search" component={BrowserSearch} />
</> </>
@ -60,27 +57,49 @@ function BrowserToolbar() {
); );
} }
export interface BrowserSidebarCategory { function BrowserCategories() {
name: string; const browser = useBrowser();
sub: [string, string][]; const location = useLocation();
};
const getEnabledCategories = () => browser.searchQuery().categories || [];
const isEnabled = (category: string) => {
return getEnabledCategories().includes(category);
};
const toggleCategory = (category: string) => {
const enabledCategories = getEnabledCategories();
const index = enabledCategories.indexOf(category);
if (index > -1)
enabledCategories.splice(index, 1);
else
enabledCategories.push(category);
browser.setSearchQuery(prev => ({
...prev,
categories: enabledCategories,
}));
if (!location.pathname.includes('/search'))
browser.search();
};
function BrowserCategories(props: { categories: BrowserSidebarCategory[] }) {
return ( return (
<div class="top-0 grid grid-cols-[1fr_auto] h-fit min-w-50 gap-y-6"> <div class="top-0 grid grid-cols-[1fr_auto] h-fit min-w-50 gap-y-6">
<div /> <div />
<div class="flex flex-col gap-y-6"> <div class="flex flex-col gap-y-6">
<For each={props.categories.concat(browserCategories.provider())}> <For each={browserCategories.byPackageType(browser.packageType())}>
{category => ( {category => (
<div class="flex flex-col gap-y-2"> <div class="flex flex-col gap-y-2">
<h6 class="my-1">{category.name}</h6> <h6 class="my-1">{category.name}</h6>
<For each={category.sub}> <For each={category.sub}>
{sub => ( {sub => (
<a <p
href={sub[1]} class={`text-md capitalize text-fg-primary hover:text-fg-primary-hover ${isEnabled(sub) ? 'text-opacity-100! hover:text-opacity-90!' : 'text-opacity-60! hover:text-opacity-70!'}`}
class="text-md text-fg-primary capitalize hover:text-fg-secondary" onClick={() => toggleCategory(sub)}
children={sub[0]} >
/> {sub}
</p>
)} )}
</For> </For>
</div> </div>
@ -97,31 +116,21 @@ function BrowserSidebar() {
return ( return (
<div class="flex flex-col gap-y-4"> <div class="flex flex-col gap-y-4">
<div class="flex flex-col gap-y-1"> <div class="flex flex-col gap-y-1">
<h6 class="my-1">Active Cluster</h6> <div class="flex flex-col gap-y-1">
<Button <h6 class="my-1">Active Cluster</h6>
buttonStyle="secondary" <Button
children={controller.cluster()?.meta.name || 'None'} buttonStyle="secondary"
iconLeft={<Settings01Icon />} children={controller.cluster()?.meta.name || 'None'}
onClick={controller.displayClusterSelector} iconLeft={<Settings01Icon />}
/> onClick={controller.displayClusterSelector}
</div> />
</div>
<div class="flex flex-col gap-y-1">
<h6 class="my-1">Search Filters</h6>
</div> </div>
</div> </div>
); );
} }
export type BrowserContentProps = ParentProps & { export function BrowserContent(props: ParentProps) {
categories?: BrowserSidebarCategory[];
};
const categories = (packageType: PackageType) => browserCategories.byPackageType(packageType);
export function BrowserContent(props: BrowserContentProps) {
const browser = useBrowser();
return ( return (
<div class="relative h-full flex flex-1 flex-col items-center gap-2"> <div class="relative h-full flex flex-1 flex-col items-center gap-2">
<div class="h-full w-full max-w-screen-xl flex flex-1 flex-col items-center gap-y-2"> <div class="h-full w-full max-w-screen-xl flex flex-1 flex-col items-center gap-y-2">
@ -132,7 +141,7 @@ export function BrowserContent(props: BrowserContentProps) {
</div> </div>
<div class="grid grid-cols-[220px_auto_220px] w-full max-w-screen-xl gap-x-6 pb-8"> <div class="grid grid-cols-[220px_auto_220px] w-full max-w-screen-xl gap-x-6 pb-8">
<BrowserCategories categories={categories(browser.packageType()).concat(props.categories || [])} /> <BrowserCategories />
<div class="h-full flex flex-col gap-y-4"> <div class="h-full flex flex-col gap-y-4">
<div class="h-full flex-1"> <div class="h-full flex-1">

View file

@ -5,28 +5,53 @@ import SearchResultsContainer from '~ui/components/content/SearchResults';
import Spinner from '~ui/components/Spinner'; import Spinner from '~ui/components/Spinner';
import useBrowser from '~ui/hooks/useBrowser'; import useBrowser from '~ui/hooks/useBrowser';
import useCommand from '~ui/hooks/useCommand'; import useCommand from '~ui/hooks/useCommand';
import usePagination, { type PaginationOptions } from '~ui/hooks/usePagination';
function BrowserSearch() { function BrowserSearch() {
const browser = useBrowser(); const browser = useBrowser();
const [results, { refetch }] = useCommand(() => bridge.commands.searchProviderPackages('Modrinth', browser.searchQuery())); const [results, { refetch }] = useCommand(() => bridge.commands.searchProviderPackages('Modrinth', browser.searchQuery()));
createEffect(on(() => browser.searchQuery().query, () => { const paginationOptions = (): PaginationOptions => ({
itemsCount: () => results()?.total || 0,
itemsPerPage: () => browser.searchQuery().limit || 20,
});
const pagination = usePagination(paginationOptions());
createEffect(on(browser.searchQuery, () => {
refetch(); refetch();
})); }));
createEffect(on(() => results()?.total, (curr, prev) => {
if (curr !== prev)
pagination.reset(paginationOptions());
}));
createEffect(on(pagination.page, () => {
browser.setSearchQuery(prev => ({
...prev,
offset: pagination.offset(),
}));
}));
return ( return (
<BrowserContent> <BrowserContent>
<Spinner.Suspense> <pagination.Navigation />
<Show when={results() !== undefined}>
<SearchResultsContainer <div class="my-2">
category="test" <Spinner.Suspense>
header="Results" <Show when={results() !== undefined}>
provider="Modrinth" <SearchResultsContainer
results={results()!.results} category="test"
/> header="Results"
</Show> provider="Modrinth"
</Spinner.Suspense> results={results()!.results}
/>
</Show>
</Spinner.Suspense>
</div>
<pagination.Navigation />
</BrowserContent> </BrowserContent>
); );
} }

View file

@ -1,9 +1,12 @@
import type { BrowserListView, PackageType } from '@onelauncher/client/bindings'; import type { BrowserListView, PackageType } from '@onelauncher/client/bindings';
import type { BrowserSidebarCategory } from '~ui/pages/browser/BrowserRoot';
import { LOADERS, PROVIDERS } from '~utils';
export const BROWSER_VIEWS: BrowserListView[] = ['grid', 'list', 'preview'] as const; export const BROWSER_VIEWS: BrowserListView[] = ['grid', 'list', 'preview'] as const;
export interface BrowserSidebarCategory {
name: string;
sub: string[];
};
export const browserCategories = { export const browserCategories = {
byPackageType(packageType: PackageType): BrowserSidebarCategory[] { byPackageType(packageType: PackageType): BrowserSidebarCategory[] {
switch (packageType) { switch (packageType) {
@ -16,6 +19,8 @@ export const browserCategories = {
case 'shaderpack': case 'shaderpack':
return this.shaderpack(); return this.shaderpack();
} }
return [];
}, },
mod(): BrowserSidebarCategory[] { mod(): BrowserSidebarCategory[] {
@ -23,15 +28,14 @@ export const browserCategories = {
{ {
name: 'Categories', name: 'Categories',
sub: [ sub: [
['Adventure', '/adventure'], 'adventure',
['Magic', '/magic'], 'magic',
['Technology', '/technology'], 'technology',
['Utility', '/utility'], 'utility',
['Worldgen', '/worldgen'], 'worldgen',
['Miscellaneous', '/miscellaneous'], 'miscellaneous',
], ],
}, },
...this.loader(false),
]; ];
}, },
@ -46,31 +50,4 @@ export const browserCategories = {
shaderpack(): BrowserSidebarCategory[] { shaderpack(): BrowserSidebarCategory[] {
return []; return [];
}, },
provider(): BrowserSidebarCategory[] {
return [
{
name: 'Providers',
sub: PROVIDERS.map(provider => [provider, `/${provider.toLowerCase()}`]),
},
];
},
loader(vanilla: boolean = false): BrowserSidebarCategory[] {
const loaders: [string, string][] = [];
for (const loader of LOADERS) {
if (vanilla === false && loader === 'vanilla')
continue;
loaders.push([loader, `/${loader.toLowerCase()}`]);
}
return [
{
name: 'Loaders',
sub: loaders,
},
];
},
}; };

View file

@ -8,6 +8,7 @@ async fn main() -> Result<()> {
let results = provider.search( let results = provider.search(
Some("chatting".to_string()), Some("chatting".to_string()),
Some(5), Some(5),
Some(0),
None, None,
None, None,
None, None,

View file

@ -44,6 +44,7 @@ impl Providers {
&self, &self,
query: Option<String>, query: Option<String>,
limit: Option<u8>, limit: Option<u8>,
offset: Option<u32>,
game_versions: Option<Vec<String>>, game_versions: Option<Vec<String>>,
categories: Option<Vec<String>>, categories: Option<Vec<String>>,
loaders: Option<Vec<Loader>>, loaders: Option<Vec<Loader>>,
@ -54,6 +55,7 @@ impl Providers {
Providers::Modrinth => modrinth::search( Providers::Modrinth => modrinth::search(
query, query,
limit, limit,
offset,
Some(|mut builder: FacetBuilder| { Some(|mut builder: FacetBuilder| {
if let Some(game_versions) = game_versions { if let Some(game_versions) = game_versions {
for version in game_versions { for version in game_versions {
@ -63,7 +65,7 @@ impl Providers {
if let Some(categories) = categories { if let Some(categories) = categories {
for category in categories { for category in categories {
builder.or(Facet( builder.and(Facet(
"categories".to_string(), "categories".to_string(),
FacetOperation::Eq, FacetOperation::Eq,
category, category,
@ -73,7 +75,7 @@ impl Providers {
if let Some(package_types) = package_types { if let Some(package_types) = package_types {
for package_type in package_types { for package_type in package_types {
builder.or(Facet( builder.and(Facet(
"project_types".to_string(), "project_types".to_string(),
FacetOperation::Eq, FacetOperation::Eq,
package_type.get_name().to_string(), package_type.get_name().to_string(),

View file

@ -36,8 +36,7 @@ pub struct ModrinthPackage {
// pub donation_urls: Vec<DonationUrl>, // pub donation_urls: Vec<DonationUrl>,
pub project_type: PackageType, pub project_type: PackageType,
pub downloads: u32, pub downloads: u32,
#[serde(default)] pub icon_url: Option<String>,
pub icon_url: String,
#[serde(alias = "project_id")] #[serde(alias = "project_id")]
pub id: String, pub id: String,
pub team: String, pub team: String,
@ -57,6 +56,8 @@ pub struct ModrinthPackage {
pub license: Option<License>, pub license: Option<License>,
// #[serde(default)] // #[serde(default)]
// pub gallery: Vec<Gallery>, // pub gallery: Vec<Gallery>,
#[serde(default)]
pub status: String,
} }
impl From<ModrinthPackage> for ManagedPackage { impl From<ModrinthPackage> for ManagedPackage {
@ -71,7 +72,7 @@ impl From<ModrinthPackage> for ManagedPackage {
versions: value.versions, versions: value.versions,
game_versions: value.game_versions, game_versions: value.game_versions,
loaders: value.loaders, loaders: value.loaders,
icon_url: Some(value.icon_url), icon_url: value.icon_url,
created: value.published, created: value.published,
updated: value.updated, updated: value.updated,
client: value.client_side, client: value.client_side,
@ -87,6 +88,7 @@ impl From<ModrinthPackage> for ManagedPackage {
team: value.team, team: value.team,
organization: value.organization, organization: value.organization,
}, },
is_archived: value.status == "archived",
} }
} }
} }
@ -114,6 +116,7 @@ pub struct Gallery {
#[derive(Deserialize)] #[derive(Deserialize)]
struct SearchResults { struct SearchResults {
hits: Vec<SearchResult>, hits: Vec<SearchResult>,
total_hits: u32,
} }
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -241,20 +244,23 @@ impl FacetBuilder {
} }
pub fn build(&self) -> String { pub fn build(&self) -> String {
let mut builder = String::new(); let mut builder: Vec<String> = vec![];
for facet in &self.facets { for facet in &self.facets {
builder.push('['); let mut stringified = String::new();
stringified.push('[');
for (i, f) in facet.iter().enumerate() { for (i, f) in facet.iter().enumerate() {
if i != 0 { if i != 0 {
builder.push(','); stringified.push(',');
} }
builder.push_str(format!("\"{}\"", f.to_string()).as_str()); stringified.push_str(format!("\"{}\"", f.to_string()).as_str());
} }
builder.push(']'); stringified.push(']');
builder.push(stringified);
} }
builder builder.join(",")
} }
} }
@ -306,6 +312,7 @@ pub async fn list() -> Result<Vec<ModrinthPackage>> {
pub async fn search<F>( pub async fn search<F>(
query: Option<String>, query: Option<String>,
limit: Option<u8>, limit: Option<u8>,
offset: Option<u32>,
facets: Option<F>, facets: Option<F>,
) -> Result<ProviderSearchResults> ) -> Result<ProviderSearchResults>
where where
@ -316,9 +323,10 @@ where
let results: SearchResults = serde_json::from_slice( let results: SearchResults = serde_json::from_slice(
&fetch( &fetch(
format_url!( format_url!(
"/search?query={}&limit={}{}", "/search?query={}&limit={}&offset={}{}",
query.unwrap_or_default(), query.unwrap_or_default(),
limit.unwrap_or(10), limit.unwrap_or(10),
offset.unwrap_or(0),
if facets.is_empty() { if facets.is_empty() {
"".to_string() "".to_string()
} else { } else {
@ -335,6 +343,7 @@ where
Ok(ProviderSearchResults { Ok(ProviderSearchResults {
provider: Providers::Modrinth, provider: Providers::Modrinth,
results: results.hits, results: results.hits,
total: results.total_hits,
}) })
} }

View file

@ -155,6 +155,7 @@ pub struct SearchResult {
pub struct ProviderSearchResults { pub struct ProviderSearchResults {
pub provider: Providers, pub provider: Providers,
pub results: Vec<SearchResult>, pub results: Vec<SearchResult>,
pub total: u32,
} }
/// A struct that represents a Package. /// A struct that represents a Package.
@ -587,8 +588,9 @@ pub struct ManagedPackage {
pub categories: Vec<String>, pub categories: Vec<String>,
pub optional_categories: Option<Vec<String>>, pub optional_categories: Option<Vec<String>>,
pub license: Option<License>, pub license: Option<License>,
pub author: Author, pub author: Author,
pub is_archived: bool,
} }
#[cfg_attr(feature = "specta", derive(specta::Type))] #[cfg_attr(feature = "specta", derive(specta::Type))]