feat: BrowserPackage version list (3/4 done)

chore: Make ErrorBoundary only work during prod
This commit is contained in:
LynithDev 2024-09-01 17:05:03 +02:00
parent 82485a509c
commit c31f1086c7
13 changed files with 322 additions and 64 deletions

5
Cargo.lock generated
View file

@ -4398,6 +4398,7 @@ dependencies = [
"tracing-appender",
"tracing-error",
"tracing-subscriber",
"url",
"uuid 1.10.0",
"whoami",
"winreg 0.52.0",
@ -7261,9 +7262,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.0.0-rc.3"
version = "2.0.0-rc.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc103bde77870e08d5fc8765615b9615997827550b626fbc4ebbd7a1fbfe2a2"
checksum = "7d5ad5fcfaf02cf79aa6727f6c5df38567d8dce172b00b62690c6bc46c08b7ce"
dependencies = [
"anyhow",
"cargo_toml",

View file

@ -43,7 +43,7 @@ tauri = { version = "=2.0.0-rc.3", features = [
] }
# tauri-build is the codegen utility for our tauri buildscript.
tauri-build = { version = "=2.0.0-rc.3" }
tauri-build = { version = "=2.0.0-rc.7" }
# tauri-plugin-shell adds a bridge for opening urls and paths in the frontend.
tauri-plugin-shell = { version = "=2.0.0-rc.1" }
# tauri-plugin-window-state saves the window position and size to be restored.

View file

@ -59,6 +59,9 @@ macro_rules! collect_commands {
// Provider Packages
get_provider_package,
get_provider_packages,
get_all_provider_package_versions,
get_provider_package_versions,
get_provider_package_version,
search_provider_packages,
get_provider_authors,
download_provider_package,

View file

@ -1,5 +1,5 @@
use onelauncher::cluster::content::package;
use onelauncher::data::{Loader, ManagedPackage, ManagedUser, PackageType};
use onelauncher::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
use onelauncher::package::content::Providers;
use onelauncher::store::{Author, ClusterPath, Package, PackagePath, ProviderSearchResults};
use uuid::Uuid;
@ -22,6 +22,35 @@ pub async fn get_provider_packages(
Ok(provider.get_multiple(&project_ids).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_all_provider_package_versions(
provider: Providers,
project_id: String,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<Loader>>,
) -> Result<Vec<ManagedVersion>, String> {
Ok(provider.get_all_versions(&project_id, game_versions, loaders).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_provider_package_versions(
provider: Providers,
versions: Vec<String>,
) -> Result<Vec<ManagedVersion>, String> {
Ok(provider.get_versions(versions).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_provider_package_version(
provider: Providers,
version: String,
) -> Result<ManagedVersion, String> {
Ok(provider.get_version(&version).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn search_provider_packages(
@ -75,6 +104,9 @@ pub async fn download_provider_package(
Ok(())
}
#[specta::specta]
#[tauri::command]
pub async fn get_cluster_package(

View file

@ -1,22 +1,25 @@
import type { ParentProps } from 'solid-js';
import { ErrorBoundary as Boundary } from 'solid-js';
import { useDebugState } from '@onelauncher/client';
import Button from './base/Button';
import Popup from './overlay/Popup';
import { ErrorBoundary as Boundary, Match, Switch } from 'solid-js';
// TODO: Uncomment code
function ErrorBoundary(props: ParentProps) {
return (
<Boundary
fallback={err => (
<div class="border border-danger rounded-md bg-danger/20 p-2">
<h2>An unexpected error has occurred</h2>
<p>{err.toString()}</p>
</div>
)}
>
{props.children}
</Boundary>
<Switch>
<Match when={import.meta.env.DEV !== true}>
<Boundary
fallback={err => (
<div class="border border-danger rounded-md bg-danger/20 p-2">
<h2>An unexpected error has occurred</h2>
<p>{err.toString()}</p>
</div>
)}
>
{props.children}
</Boundary>
</Match>
<Match when>
{props.children}
</Match>
</Switch>
);
}

View file

@ -1,7 +1,7 @@
.button {
@apply py-1.5 px-3 rounded-lg flex flex-row justify-center items-center gap-x-2.5;
&__primary {
&__primary, &__iconPrimary {
@apply text-onbrand hover:text-onbrand-hover active:text-onbrand-pressed disabled:text-onbrand-disabled;
@apply bg-brand hover:bg-brand-hover active:bg-brand-pressed disabled:bg-brand-disabled;
}
@ -25,7 +25,7 @@
@apply hover:bg-gray-05 [&[aria-checked="true"]]:bg-gray-10 active:bg-gray-10;
}
&__icon, &__iconSecondary, &__iconDanger {
&__icon, &__iconPrimary, &__iconSecondary, &__iconDanger {
@apply p-1.5 aspect-square box-border w-8 h-8;
&.large {

View file

@ -5,7 +5,7 @@ import styles from './Button.module.scss';
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & ParentProps & {
iconLeft?: JSX.Element;
iconRight?: JSX.Element;
buttonStyle?: 'primary' | 'secondary' | 'danger' | 'icon' | 'iconSecondary' | 'iconDanger' | 'ghost';
buttonStyle?: 'primary' | 'secondary' | 'danger' | 'icon' | 'iconPrimary' | 'iconSecondary' | 'iconDanger' | 'ghost';
large?: boolean;
};

View file

@ -1,8 +1,8 @@
import { For, type ParentProps, Show, createContext, createEffect, createSignal, useContext } from 'solid-js';
import { For, type ParentProps, Show, createContext, createEffect, createSignal, on, useContext } from 'solid-js';
import { A, type Params, Route, useSearchParams } from '@solidjs/router';
import { CalendarIcon, ChevronDownIcon, ClockRewindIcon, Download01Icon, File02Icon, HeartIcon, LinkExternal01Icon } from '@untitled-theme/icons-solid';
import type { Cluster, ManagedPackage, ManagedUser, Providers } from '@onelauncher/client/bindings';
import { getLicenseUrl, getPackageUrl } from '../../../utils';
import { CalendarIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClockRewindIcon, Download01Icon, File02Icon, HeartIcon, LinkExternal01Icon } from '@untitled-theme/icons-solid';
import type { Cluster, ManagedPackage, ManagedUser, ManagedVersion, Providers } from '@onelauncher/client/bindings';
import { getLicenseUrl, getPackageUrl, upperFirst } from '../../../utils';
import { bridge } from '~imports';
import useCommand from '~ui/hooks/useCommand';
import { abbreviateNumber, formatAsRelative } from '~utils';
@ -37,7 +37,7 @@ function BrowserPackageRoutes() {
return (
<>
<Route path="/" component={BrowserPackageBody} />
<Route path="/gallery" component={BrowserPackageGallery} />
{/* <Route path="/gallery" component={BrowserPackageGallery} /> */}
<Route path="/versions" component={BrowserPackageVersions} />
</>
);
@ -68,7 +68,7 @@ function BrowserPackage(props: ParentProps) {
const links = [
['About', '/'],
['Gallery', '/gallery'],
// ['Gallery', '/gallery'],
['Versions', '/versions'],
];
@ -170,24 +170,29 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
<h4 class="text-fg-primary font-bold">Authors</h4>
<For each={props.authors}>
{author => (
<div
class="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
onClick={() => promptOpen(author.url)}
>
<img class="h-8 min-h-8 min-w-8 w-8 rounded-md" src={author.avatar_url || SteveHead} alt={`${author.username}'s avatar`} />
<div class="flex flex-1 flex-col justify-center gap-y-1">
<span>{author.username}</span>
<>
<div
class="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
onClick={() => promptOpen(author.url)}
>
<img class="h-8 min-h-8 min-w-8 w-8 rounded-md" src={author.avatar_url || SteveHead} alt={`${author.username}'s avatar`} />
<div class="flex flex-1 flex-col justify-center gap-y-1">
<span>{author.username}</span>
<Show when={author.is_organization_user}>
<span class="text-xs text-fg-secondary">Organization</span>
</Show>
<Show when={author.is_organization_user}>
<span class="text-xs text-fg-secondary">Organization</span>
</Show>
<Show when={author.role !== null}>
<span class="text-xs text-fg-secondary">{author.role}</span>
</Show>
<Show when={author.role !== null}>
<span class="text-xs text-fg-secondary">{author.role}</span>
</Show>
</div>
<LinkExternal01Icon class="h-4 w-4" />
</div>
<LinkExternal01Icon class="h-4 w-4" />
</div>
<Show when={author.is_organization_user === true}>
<div class="h-px w-full bg-gray-05" />
</Show>
</>
)}
</For>
</div>
@ -335,27 +340,190 @@ function BrowserPackageBody() {
);
}
function BrowserPackageGallery() {
// const context = useContext(BrowserPackageContext);
// function BrowserPackageGallery() {
// // const context = useContext(BrowserPackageContext);
return (
<div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
<div>Gallery</div>
{/* <For each={[]}>
{image => (
<img src={image.url} alt={image.alt} />
)}
</For> */}
</div>
);
}
// return (
// <div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
// <div>Gallery</div>
// {/* <For each={[]}>
// {image => (
// <img src={image.url} alt={image.alt} />
// )}
// </For> */}
// </div>
// );
// }
function BrowserPackageVersions() {
// const context = useContext(BrowserPackageContext);
const context = useContext(BrowserPackageContext);
const [page, setPage] = createSignal(1);
const MAX_ITEMS_PER_PAGE = 20;
const getVersionsForPage = (page: number) => {
if (context === null)
return [];
const newestToOldest = context.versions.toReversed();
const start = (page - 1) * MAX_ITEMS_PER_PAGE;
const end = start + MAX_ITEMS_PER_PAGE;
return newestToOldest.slice(start, end);
};
const getMaxPage = () => {
if (context === null)
return 1;
return Math.ceil(context.versions.length / MAX_ITEMS_PER_PAGE);
};
const diffFrom = (from: number = page()) => getMaxPage() - from;
const [versions, { refetch }] = useCommand(context, async () => {
if (context === null)
return { error: 'No package context', status: 'error' };
const list = getVersionsForPage(page());
return await bridge.commands.getProviderPackageVersions(context.provider, list);
});
function PaginationBtn(props: { page: number }) {
return (
<Button
buttonStyle={props.page === page() ? 'iconPrimary' : 'iconSecondary'}
children={props.page}
onClick={() => {
if (props.page === page())
return;
setPage(props.page);
}}
/>
);
}
let container!: HTMLDivElement;
createEffect(on(() => page(), () => {
refetch();
container.scrollIntoView({ behavior: 'smooth' });
}));
return (
<div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
<div>versions</div>
<div ref={container} class="w-full flex flex-1 flex-col gap-y-1">
<h1>
Versions - Page
{' '}
{page()}
</h1>
<table class="w-full border-separate border-spacing-x-none border-spacing-y-1">
<thead>
<tr class="bg-page-elevated [&>th]:py-2 [&>th]:text-left">
<th class="w-16 rounded-l-lg" />
<th>Name</th>
<th>Game Version</th>
<th>Loader</th>
<th>Created</th>
<th>Downloads</th>
<th class="rounded-r-lg" />
</tr>
</thead>
<tbody>
<For each={versions()?.reverse()}>
{version => <VersionRow {...version} />}
</For>
</tbody>
</table>
<div class="flex flex-row gap-x-1">
<Button
buttonStyle="iconSecondary"
children={<ChevronLeftIcon />}
disabled={page() <= 1}
onClick={() => setPage(page => page - 1)}
/>
<PaginationBtn page={1} />
{/* TODO: Pagination */}
{/* <Switch>
<Match when={}
</Switch> */}
<Button
buttonStyle="iconSecondary"
children={<ChevronRightIcon />}
disabled={page() >= getMaxPage()}
onClick={() => setPage(page => page + 1)}
/>
</div>
</div>
);
}
function VersionRow(props: ManagedVersion) {
function colorForType(type: string) {
switch (type) {
case 'release':
return 'bg-code-trace';
case 'snapshot':
return 'bg-code-debug';
case 'beta':
return 'bg-code-warn';
case 'alpha':
return 'bg-code-error';
default:
return 'bg-gray-05';
}
}
return (
<tr class="my-2 bg-page-elevated px-4 [&>td]:py-4">
<td class="rounded-l-lg px-4">
<Tooltip text={upperFirst(props.version_type)}>
<div class={`${colorForType(props.version_type)} h-8 w-8 flex items-center justify-center rounded-md bg-opacity-30`}>
<span class="font-bold">{props.version_type.charAt(0).toUpperCase()}</span>
</div>
</Tooltip>
</td>
<td>
<div class="flex flex-col gap-2">
<h3 class="text-lg">{props.version_id}</h3>
<p class="text-wrap text-sm">{props.name}</p>
</div>
</td>
<td>
{props.game_versions.join(', ')}
</td>
<td>
{props.loaders.map(upperFirst).join(', ')}
</td>
<td>
{formatAsRelative(new Date(props.published).getTime(), 'en', 'long')}
</td>
<td>
{props.downloads}
</td>
<td class="rounded-r-lg">
<Button
buttonStyle="iconSecondary"
children={<Download01Icon />}
/>
</td>
</tr>
);
}

View file

@ -67,6 +67,7 @@ async-compression = { workspace = true }
flate2 = { workspace = true }
toml = { workspace = true }
nom = { workspace = true }
url = { workspace = true }
# features[tauri]
tauri = { workspace = true, optional = true }

View file

@ -20,7 +20,11 @@ pub async fn find_managed_version(
) -> Result<ManagedVersion> {
let provider = package.provider.clone();
let versions = provider.get_versions(&package.id).await?;
let versions = provider.get_all_versions(
&package.id,
game_version.as_ref().map(|v| vec![v.to_owned()]).to_owned(),
loader.map(|l| vec![l]).clone(),
).await?;
Ok(versions
.iter()

View file

@ -125,9 +125,23 @@ impl Providers {
.collect())
}
pub async fn get_versions(&self, project_id: &str) -> Result<Vec<ManagedVersion>> {
pub async fn get_all_versions(&self,
project_id: &str,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<Loader>>
) -> Result<Vec<ManagedVersion>> {
Ok(match self {
Providers::Modrinth => modrinth::get_versions(project_id),
Providers::Modrinth => modrinth::get_all_versions(project_id, game_versions, loaders),
}
.await?
.into_iter()
.map(|p| p.into())
.collect())
}
pub async fn get_versions(&self, versions: Vec<String>) -> Result<Vec<ManagedVersion>> {
Ok(match self {
Providers::Modrinth => modrinth::get_versions(versions),
}
.await?
.into_iter()

View file

@ -429,10 +429,38 @@ async fn get_team(team_id: String) -> Result<Vec<ManagedUser>> {
// TODO: modrinth api v3
// pub async fn get_org_projects(organization: &str) -> Result<>
pub async fn get_versions(project_id: &str) -> Result<Vec<ModrinthVersion>> {
pub async fn get_all_versions(
project_id: &str,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<Loader>>,
) -> Result<Vec<ModrinthVersion>> {
let mut url = url::Url::parse(format_url!("/project/{}/version", project_id).as_str())?;
if let Some(game_versions) = game_versions {
url.query_pairs_mut()
.append_pair("game_versions", &game_versions.iter().map(|v| format!("\"{}\"", v.to_string())).collect::<Vec<String>>().join(","));
}
if let Some(loaders) = loaders {
url.query_pairs_mut()
.append_pair("loaders", format!("[{}]", &loaders.iter().map(|l| format!("\"{}\"", l.to_string())).collect::<Vec<String>>().join(",")).as_str());
}
Ok(serde_json::from_slice(
&fetch(
format_url!("/project/{}/version", project_id).as_str(),
format_url!(
"/project/{}/version",
project_id).as_str(),
None,
&State::get().await?.fetch_semaphore,
)
.await?,
)?)
}
pub async fn get_versions(versions: Vec<String>) -> Result<Vec<ModrinthVersion>> {
Ok(serde_json::from_slice(
&fetch(
format_url!("/versions?ids=[{}]", versions.iter().map(|v| format!("\"{}\"", v)).collect::<Vec<String>>().join(",")).as_str(),
None,
&State::get().await?.fetch_semaphore,
)

View file

@ -115,6 +115,10 @@ pub enum ErrorKind {
#[error("failed to manage credentials with IOTA stronghold: {0}")]
StrongholdError(#[from] crate::store::StrongholdError),
/// Wrapper around [`url::ParseError`] to handle URL parsing errors
#[error("failed to parse and handle URLs: {0}")]
URLError(#[from] url::ParseError),
/// Wrapper around [`tauri::Error`] to handle Tauri errors when the feature flag is enabled
#[cfg(feature = "tauri")]
#[error("error handling tauri: {0}")]