Merge branch 'refactor/frontend' into oneclient/main
This commit is contained in:
commit
c3a851d77e
63 changed files with 2737 additions and 431 deletions
60
Cargo.lock
generated
60
Cargo.lock
generated
|
|
@ -2512,6 +2512,25 @@ version = "2.10.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4"
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
|
|
@ -3482,6 +3501,7 @@ dependencies = [
|
|||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
|
|
@ -3512,6 +3532,18 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
|
@ -3673,6 +3705,12 @@ version = "1.0.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
|
|
@ -6010,6 +6048,28 @@ dependencies = [
|
|||
"uuid 1.16.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c8983f50326d34437142a6d560b5c3426e91324297519b6eeb32ed0a1d1e0f2"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.3.1",
|
||||
"open",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.12",
|
||||
"url",
|
||||
"windows 0.61.1",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.2.3"
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ tauri-plugin-dialog = { version = "=2.2.1" }
|
|||
tauri-plugin-single-instance = { version = "=2.2.3" }
|
||||
tauri-plugin-updater = { version = "=2.7.1" }
|
||||
tauri-plugin-window-state = { version = "=2.2.2" }
|
||||
tauri-plugin-opener = { version = "=2.3.0" }
|
||||
|
||||
# code gen
|
||||
strum = { version = "=0.27.1", features = [ "derive" ] }
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ tauri-plugin-updater = { workspace = true }
|
|||
tauri-plugin-clipboard-manager = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-deep-link = { workspace = true }
|
||||
tauri-plugin-opener = { workspace = true }
|
||||
|
||||
# code gen
|
||||
taurpc = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "capabilities for the main window",
|
||||
"windows": ["*"],
|
||||
"windows": [
|
||||
"*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:app:default",
|
||||
"core:webview:default",
|
||||
|
|
@ -12,7 +14,6 @@
|
|||
"core:resources:default",
|
||||
"core:tray:default",
|
||||
"core:window:default",
|
||||
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-destroy",
|
||||
|
|
@ -24,13 +25,23 @@
|
|||
"core:window:allow-set-decorations",
|
||||
"core:window:allow-set-title-bar-style",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
|
||||
"core:event:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-confirm",
|
||||
"deep-link:default",
|
||||
"updater:default"
|
||||
"updater:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$DATA/OneLauncher/**"
|
||||
},
|
||||
{
|
||||
"path": "$DATA/OneLauncher"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ pub trait OneLauncherApi {
|
|||
async fn return_error() -> LauncherResult<()>;
|
||||
|
||||
async fn open_dev_tools<R: Runtime>(webview_window: tauri::WebviewWindow<R>);
|
||||
|
||||
async fn set_window_style<R: Runtime>(webview_window: tauri::WebviewWindow<R>, decorations: bool);
|
||||
}
|
||||
|
||||
#[taurpc::ipc_type]
|
||||
|
|
@ -24,4 +26,7 @@ impl OneLauncherApi for OneLauncherApiImpl {
|
|||
webview_window.open_devtools();
|
||||
}
|
||||
|
||||
async fn set_window_style<R: Runtime>(self, webview_window: tauri::WebviewWindow<R>, decorations: bool) {
|
||||
webview_window.set_decorations(decorations).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ async fn initialize_tauri(builder: tauri::Builder<tauri::Wry>) -> LauncherResult
|
|||
.export_config(
|
||||
specta_typescript::Typescript::default()
|
||||
.bigint(specta_typescript::BigIntExportBehavior::BigInt)
|
||||
.formatter(ext::specta::formatter),
|
||||
.formatter(ext::specta::formatter)
|
||||
.header("// @ts-nocheck \n"),
|
||||
)
|
||||
.merge(api::commands::OneLauncherApiImpl.into_handler())
|
||||
.merge(onelauncher_core::api::tauri::TauriLauncherApiImpl.into_handler())
|
||||
|
|
@ -70,9 +71,16 @@ async fn initialize_tauri(builder: tauri::Builder<tauri::Wry>) -> LauncherResult
|
|||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.menu(tauri::menu::Menu::new)
|
||||
.invoke_handler(router.into_handler())
|
||||
.setup(move |app| {
|
||||
let launcher_path = app.path().data_dir().ok().expect("ok").join("OneLauncher");
|
||||
|
||||
app.asset_protocol_scope()
|
||||
.allow_directory(&launcher_path, true)
|
||||
.unwrap();
|
||||
|
||||
setup_window(app.handle()).expect("failed to setup main window");
|
||||
Ok(())
|
||||
});
|
||||
|
|
@ -114,11 +122,16 @@ fn setup_window(handle: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Err
|
|||
.get_webview_window("main")
|
||||
.ok_or_else(|| anyhow::anyhow!("no window called main was found"))?;
|
||||
|
||||
// tokio::task::spawn(async move {
|
||||
// // let state = State::get().await.expect("failed to get state");
|
||||
// // let settings = state.settings.read().await;
|
||||
// // win.set_decorations(settings.);
|
||||
// });
|
||||
// Initialize window decorations based on settings
|
||||
let win_clone = win.clone();
|
||||
tokio::task::spawn(async move {
|
||||
if let Ok(state) = State::get().await {
|
||||
let settings = state.settings.read().await;
|
||||
// native_window_frame=true means use native decorations
|
||||
// native_window_frame=false means use custom frame (no decorations)
|
||||
win_clone.set_decorations(settings.native_window_frame).ok();
|
||||
}
|
||||
});
|
||||
|
||||
win.show()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-opener": "^2.3.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@untitled-theme/icons-react": "^0.14.0",
|
||||
"motion": "^12.10.5",
|
||||
|
|
@ -37,7 +38,8 @@
|
|||
"tailwindcss": "^4.0.6",
|
||||
"tailwindcss-animated": "^2.0.0",
|
||||
"tailwindcss-react-aria-components": "^2.0.0",
|
||||
"taurpc": "^1.8.1"
|
||||
"taurpc": "^1.8.1",
|
||||
"valtio": "^2.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/eslint-config": "^0.1.0",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import type { ToOptions } from '@tanstack/react-router';
|
||||
import useNotifications from '@/hooks/useNotification';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, Menu } from '@onelauncher/common/components';
|
||||
import { Button, Popup } from '@onelauncher/common/components';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Bell01Icon, Cloud01Icon, TerminalBrowserIcon } from '@untitled-theme/icons-react';
|
||||
import { MenuTrigger } from 'react-aria-components';
|
||||
import OneLauncherText from '../assets/logos/onelauncher_text.svg';
|
||||
import PlayerHead from './content/PlayerHead';
|
||||
import AccountPopup from './overlay/popups/AccountPopup';
|
||||
import NotificationPopup from './overlay/popups/NotificationPopup';
|
||||
|
||||
function Navbar() {
|
||||
const defaultUser = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false));
|
||||
const { list } = useNotifications();
|
||||
const notificationCount = Object.keys(list).length;
|
||||
|
||||
return (
|
||||
<div className="h-15 min-h-15 flex flex-row items-center *:flex-1">
|
||||
|
|
@ -36,36 +39,31 @@ function Navbar() {
|
|||
|
||||
{/* Notification Manager Button */}
|
||||
<div className="relative">
|
||||
<MenuTrigger>
|
||||
<Button color="ghost" size="icon">
|
||||
<Popup.Trigger>
|
||||
<Button className="relative" color="ghost" size="icon">
|
||||
<Bell01Icon />
|
||||
{notificationCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
||||
{notificationCount > 99 ? '99+' : notificationCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Menu.Popover placement="bottom right">
|
||||
<Menu>
|
||||
<Menu.Item>Hello World</Menu.Item>
|
||||
</Menu>
|
||||
</Menu.Popover>
|
||||
</MenuTrigger>
|
||||
|
||||
{/* <NotificationPopup
|
||||
className="mt-2"
|
||||
mount={notificationButtonContainer}
|
||||
ref={el => el.classList.add('right-0')}
|
||||
setVisible={setNotificationMenuOpen}
|
||||
visible={notificationMenuOpen}
|
||||
/> */}
|
||||
<Popup>
|
||||
<NotificationPopup />
|
||||
</Popup>
|
||||
</Popup.Trigger>
|
||||
</div>
|
||||
|
||||
<MenuTrigger>
|
||||
<Popup.Trigger>
|
||||
<Button className="p-0" color="ghost" size="icon">
|
||||
<PlayerHead className="h-full rounded-md hover:opacity-70" uuid={defaultUser.data?.id} />
|
||||
</Button>
|
||||
|
||||
<Menu.Popover>
|
||||
<Popup>
|
||||
<AccountPopup />
|
||||
</Menu.Popover>
|
||||
</MenuTrigger>
|
||||
</Popup>
|
||||
</Popup.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ function SettingsRow({ disabled, description, icon, title, children }: SettingsR
|
|||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<h3 className="text-lg capitalize leading-tight">{title}</h3>
|
||||
<p className="text-lg capitalize leading-tight">{title}</p>
|
||||
<p className="text-sm text-fg-secondary">{description}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ComponentType, JSX } from 'react';
|
||||
import type { PressEvent } from 'react-aria-components';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import useSettings from '@/hooks/useSettings';
|
||||
import { Button, Show } from '@onelauncher/common/components';
|
||||
import { Window } from '@tauri-apps/api/window';
|
||||
import { ChevronLeftIcon, Maximize02Icon, Minimize01Icon, MinusIcon, XCloseIcon } from '@untitled-theme/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
|
@ -8,6 +9,7 @@ import { twMerge } from 'tailwind-merge';
|
|||
|
||||
function WindowFrame() {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const back = () => history.back();
|
||||
const minimize = () => Window.getCurrent().minimize();
|
||||
|
|
@ -27,17 +29,19 @@ function WindowFrame() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="z-[99999] w-screen flex flex-row items-center justify-between gap-0.5 bg-window-frame" data-tauri-drag-region>
|
||||
<div className="flex-1 flex flex-row items-center pointer-events-none">
|
||||
<TitlebarButton icon={ChevronLeftIcon} onClick={back} />
|
||||
</div>
|
||||
<Show when={settings?.native_window_frame === false}>
|
||||
<div className="z-[99999] w-screen flex flex-row items-center justify-between gap-0.5 bg-window-frame" data-tauri-drag-region>
|
||||
<div className="flex-1 flex flex-row items-center pointer-events-none">
|
||||
<TitlebarButton icon={ChevronLeftIcon} onClick={back} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-row items-center justify-end pointer-events-none">
|
||||
<TitlebarButton icon={MinusIcon} onClick={minimize} />
|
||||
<TitlebarButton icon={isMaximized ? Minimize01Icon : Maximize02Icon} onClick={toggleMaximize} />
|
||||
<TitlebarButton danger icon={XCloseIcon} onClick={quit} />
|
||||
<div className="flex-1 flex flex-row items-center justify-end pointer-events-none">
|
||||
<TitlebarButton icon={MinusIcon} onClick={minimize} />
|
||||
<TitlebarButton icon={isMaximized ? Minimize01Icon : Maximize02Icon} onClick={toggleMaximize} />
|
||||
<TitlebarButton danger icon={XCloseIcon} onClick={quit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import type { ImgHTMLAttributes, RefAttributes } from 'react';
|
||||
import CurseforgeIcon from '@/assets/logos/curseforge.svg';
|
||||
import VanillaImage from '@/assets/logos/minecraft.png';
|
||||
import ModrinthIcon from '@/assets/logos/modrinth.svg';
|
||||
import SkyClientImage from '@/assets/logos/skyclient.png';
|
||||
|
||||
export function getProviderLogoSrc(provider: string): string {
|
||||
const providerName = provider.toLowerCase();
|
||||
|
||||
const mapping: Record<string, string> = {
|
||||
modrinth: ModrinthIcon,
|
||||
curseforge: CurseforgeIcon,
|
||||
skyclient: SkyClientImage,
|
||||
};
|
||||
|
||||
return mapping[providerName];
|
||||
}
|
||||
|
||||
type ProviderIconProp = ImgHTMLAttributes<HTMLImageElement> & RefAttributes<HTMLImageElement> & {
|
||||
provider: string | undefined;
|
||||
};
|
||||
|
||||
function ProviderIcon({
|
||||
provider,
|
||||
src: _src,
|
||||
alt: _alt,
|
||||
...rest
|
||||
}: ProviderIconProp) {
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
alt={`${provider}'s logo`}
|
||||
src={provider ? getProviderLogoSrc(provider) : VanillaImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderIcon;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import DefaultWorld from '@/assets/images/default_world.png';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { dataDir, join } from '@tauri-apps/api/path';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface WorldIconProps {
|
||||
world_name: string;
|
||||
cluster_name: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function WorldIcon(props: WorldIconProps) {
|
||||
const { world_name, cluster_name, className } = props;
|
||||
const [imagePath, setImagePath] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadImagePath() {
|
||||
const iconPath = await join(await dataDir(), 'OneLauncher', 'clusters', cluster_name, 'saves', world_name, 'icon.png');
|
||||
setImagePath(convertFileSrc(iconPath));
|
||||
}
|
||||
|
||||
loadImagePath().catch(console.error);
|
||||
}, [cluster_name, world_name]);
|
||||
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${world_name} icon`}
|
||||
className={twMerge('rounded-md size-16', className)}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = DefaultWorld;
|
||||
}}
|
||||
src={imagePath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { GameLoader, Model } from '@/bindings.gen';
|
||||
import type { ClusterModel, GameLoader } from '@/bindings.gen';
|
||||
import type { ImgHTMLAttributes, RefAttributes } from 'react';
|
||||
import FabricImage from '@/assets/logos/loaders/fabric.png';
|
||||
import ForgeImage from '@/assets/logos/loaders/forge.png';
|
||||
|
|
@ -6,7 +6,7 @@ import NeoForgeImage from '@/assets/logos/loaders/neoforge.png';
|
|||
import QuiltImage from '@/assets/logos/loaders/quilt.png';
|
||||
import VanillaImage from '@/assets/logos/minecraft.png';
|
||||
|
||||
export function getLoaderLogoSrc(loader: Model | GameLoader): string {
|
||||
export function getLoaderLogoSrc(loader: ClusterModel | GameLoader): string {
|
||||
const loaderName = (typeof loader === 'string' ? loader : loader.mc_loader).toLowerCase() as GameLoader;
|
||||
|
||||
const mapping: Record<GameLoader, string> = {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import type { IngressPayload, Version, VersionType } from '@/bindings.gen';
|
||||
import type { GameLoader, Version, VersionType } from '@/bindings.gen';
|
||||
import type { JSX, RefAttributes } from 'react';
|
||||
import DefaultBanner from '@/assets/images/default_banner.png';
|
||||
import DefaultClusterBanner from '@/assets/images/default_instance_cover.jpg';
|
||||
import LauncherIcon from '@/components/content/LauncherIcon';
|
||||
import Modal from '@/components/overlay/Modal';
|
||||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import { bindings } from '@/main';
|
||||
import { LAUNCHER_IMPORT_TYPES } from '@/utils';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { LAUNCHER_IMPORT_TYPES, LOADERS, upperFirst } from '@/utils';
|
||||
import { useCommand, useCommandMut } from '@onelauncher/common';
|
||||
import { Button, Dropdown, SelectList, TextField } from '@onelauncher/common/components';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon, PlusIcon, SearchMdIcon, Server01Icon, TextInputIcon, User03Icon } from '@untitled-theme/icons-react';
|
||||
|
|
@ -26,37 +25,43 @@ const STEPS = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
interface ClusterFormData {
|
||||
loader: GameLoader;
|
||||
clusterName: string;
|
||||
clusterVersion: string;
|
||||
clusterProvider: string;
|
||||
}
|
||||
|
||||
export function NewClusterCreate() {
|
||||
const queryClient = useQueryClient();
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [formData, setFormData] = useState({
|
||||
loader: '',
|
||||
const [formData, setFormData] = useState<ClusterFormData>({
|
||||
loader: 'vanilla',
|
||||
clusterName: '',
|
||||
clusterVersion: '',
|
||||
clusterProvider: '',
|
||||
});
|
||||
|
||||
const create = useCommand('createCluster', () => bindings.core.createCluster({
|
||||
icon: DefaultClusterBanner,
|
||||
mc_loader: 'vanilla',
|
||||
const create = useCommandMut(() => bindings.core.createCluster({
|
||||
icon: null,
|
||||
mc_loader: formData.loader,
|
||||
mc_loader_version: null,
|
||||
mc_version: formData.clusterVersion,
|
||||
name: formData.clusterName,
|
||||
}), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['getClusters'] });
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function handleCreate() {
|
||||
if (!isLastInputStep)
|
||||
if (!isLastInputStep && !formData.clusterName)
|
||||
return;
|
||||
|
||||
create.refetch();
|
||||
create.mutate();
|
||||
|
||||
if (create.isError)
|
||||
console.error(create.error.message);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['getClusters'], exact: true });
|
||||
}
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
|
|
@ -78,10 +83,7 @@ export function NewClusterCreate() {
|
|||
switch (currentStepConfig.id) {
|
||||
case 'loader': {
|
||||
stepContent = (
|
||||
<ClusterLoader
|
||||
onSelectLoader={loader => setFormData(prev => ({ ...prev, loader }))}
|
||||
selectedLoader={formData.loader}
|
||||
/>
|
||||
<ClusterLoader onSelectProvider={provider => setFormData(prev => ({ ...prev, clusterProvider: provider }))} selectedProvider={formData.clusterProvider} />
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
|
@ -89,8 +91,10 @@ export function NewClusterCreate() {
|
|||
case 'info': {
|
||||
stepContent = (
|
||||
<ClusterInformation
|
||||
clusterLoader={formData.loader}
|
||||
clusterName={formData.clusterName}
|
||||
clusterVersion={formData.clusterVersion}
|
||||
onClusterLoaderChange={loader => setFormData(prev => ({ ...prev, loader }))}
|
||||
onClusterNameChange={name => setFormData(prev => ({ ...prev, clusterName: name }))}
|
||||
onClusterVersionChange={version =>
|
||||
setFormData(prev => ({ ...prev, clusterVersion: version }))}
|
||||
|
|
@ -105,7 +109,7 @@ export function NewClusterCreate() {
|
|||
const nextButtonText = isLastInputStep ? 'Create' : 'Next';
|
||||
|
||||
let isNextDisabled = false;
|
||||
if (currentStepConfig.id === 'loader' && !formData.loader)
|
||||
if (currentStepConfig.id === 'loader' && !formData.clusterProvider)
|
||||
isNextDisabled = true;
|
||||
else if (
|
||||
currentStepConfig.id === 'info'
|
||||
|
|
@ -174,19 +178,19 @@ export function NewClusterCreate() {
|
|||
}
|
||||
|
||||
interface ClusterLoaderProps {
|
||||
selectedLoader: string;
|
||||
onSelectLoader: (loader: string) => void;
|
||||
selectedProvider: string;
|
||||
onSelectProvider: (provider: string) => void;
|
||||
}
|
||||
|
||||
function ClusterLoader({ selectedLoader, onSelectLoader }: ClusterLoaderProps) {
|
||||
function ClusterLoader({ selectedProvider, onSelectProvider }: ClusterLoaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<ProviderCard
|
||||
icon={<User03Icon />}
|
||||
name="New"
|
||||
selected={selectedLoader === 'new'}
|
||||
setSelected={() => onSelectLoader('new')}
|
||||
selected={selectedProvider === 'new'}
|
||||
setSelected={() => onSelectProvider('new')}
|
||||
/>
|
||||
|
||||
{LAUNCHER_IMPORT_TYPES.map((data, i) => (
|
||||
|
|
@ -194,8 +198,8 @@ function ClusterLoader({ selectedLoader, onSelectLoader }: ClusterLoaderProps) {
|
|||
icon={<LauncherIcon launcher={data} />}
|
||||
key={i}
|
||||
name={data}
|
||||
selected={selectedLoader === data}
|
||||
setSelected={() => onSelectLoader(data)}
|
||||
selected={selectedProvider === data}
|
||||
setSelected={() => onSelectProvider(data)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -231,6 +235,8 @@ interface ClusterInformationProps {
|
|||
onClusterNameChange: (name: string) => void;
|
||||
clusterVersion: string;
|
||||
onClusterVersionChange: (version: string) => void;
|
||||
clusterLoader: GameLoader;
|
||||
onClusterLoaderChange: (loader: GameLoader) => void;
|
||||
}
|
||||
|
||||
function ClusterInformation({
|
||||
|
|
@ -238,6 +244,8 @@ function ClusterInformation({
|
|||
onClusterNameChange,
|
||||
clusterVersion,
|
||||
onClusterVersionChange,
|
||||
clusterLoader,
|
||||
onClusterLoaderChange,
|
||||
}: ClusterInformationProps) {
|
||||
const versions = useCommand('getGameVersions', bindings.core.getGameVersions);
|
||||
|
||||
|
|
@ -249,6 +257,7 @@ function ClusterInformation({
|
|||
iconLeft={<TextInputIcon className="size-4" />}
|
||||
onChange={e => onClusterNameChange(e.target.value)}
|
||||
placeholder="Name"
|
||||
required
|
||||
value={clusterName}
|
||||
/>
|
||||
</Option>
|
||||
|
|
@ -258,35 +267,16 @@ function ClusterInformation({
|
|||
</Option>
|
||||
|
||||
<Option header="Loader">
|
||||
<Dropdown className="w-full" defaultSelectedKey="vanilla">
|
||||
<Dropdown.Item>
|
||||
<div className="flex flex-row">
|
||||
<LoaderIcon className="size-5" loader="vanilla" />
|
||||
{' '}
|
||||
Vanilla
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<div className="flex flex-row">
|
||||
<LoaderIcon className="size-5" loader="fabric" />
|
||||
{' '}
|
||||
Fabric
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<div className="flex flex-row">
|
||||
<LoaderIcon className="size-5" loader="quilt" />
|
||||
{' '}
|
||||
Quilt
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<div className="flex flex-row">
|
||||
<LoaderIcon className="size-5" loader="forge" />
|
||||
{' '}
|
||||
Forge
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
{/* TODO: fixme */}
|
||||
<Dropdown defaultSelectedKey="vanilla" onSelectionChange={e => onClusterLoaderChange(e.toString() as GameLoader)} selectedKey={clusterLoader}>
|
||||
{LOADERS.map(loader => (
|
||||
<Dropdown.Item id={loader} key={loader}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<LoaderIcon className="size-5" loader={loader as GameLoader} />
|
||||
{upperFirst(loader)}
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown>
|
||||
</Option>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import type { ClassNameString } from '@onelauncher/common';
|
||||
import type { DialogProps } from 'react-aria-components';
|
||||
import {
|
||||
Dialog as AriaDialog,
|
||||
Modal as AriaModal,
|
||||
DialogTrigger,
|
||||
Modal as ModalOverlay,
|
||||
ModalOverlay,
|
||||
} from 'react-aria-components';
|
||||
|
||||
interface ModalProps extends DialogProps, ClassNameString { }
|
||||
interface ModalProps extends DialogProps { }
|
||||
|
||||
function Modal({ className, ...props }: ModalProps) {
|
||||
return (
|
||||
<ModalOverlay className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" isDismissable>
|
||||
<AriaDialog className="" {...props} />
|
||||
<AriaModal>
|
||||
<AriaDialog className="" {...props} />
|
||||
|
||||
</AriaModal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import type { NotificationData } from '@/hooks/useNotification';
|
||||
import useNotifications from '@/hooks/useNotification';
|
||||
import { Button, Show } from '@onelauncher/common/components';
|
||||
import { InfoCircleIcon, XIcon } from '@untitled-theme/icons-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface ToastNotification extends NotificationData {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface NotificationToastProps {
|
||||
notification: ToastNotification;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const fractionEnded = (fraction: number | undefined) => fraction !== undefined && fraction >= 0.97;
|
||||
|
||||
export function NotificationToast({ notification, onRemove }: NotificationToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
setIsRemoving(true);
|
||||
setTimeout(() => {
|
||||
onRemove(notification.id);
|
||||
}, 300);
|
||||
}, [notification.id, onRemove]);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger entrance animation
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-remove after 5 seconds if no progress indicator
|
||||
if (notification.fraction === undefined) {
|
||||
const timer = setTimeout(handleRemove, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [notification.fraction, handleRemove]);
|
||||
|
||||
return (
|
||||
<div className={twMerge('flex flex-col gap-y-1 p-2 bg-component-bg border border-component-border rounded-lg shadow-lg', isVisible && !isRemoving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0')}>
|
||||
<div className="grid grid-cols-[24px_1fr_auto] min-h-10 place-items-center gap-3">
|
||||
<InfoCircleIcon className="h-6 w-6 text-fg-primary" />
|
||||
|
||||
<div className="w-full flex flex-col gap-y-1">
|
||||
<span className="text-fg-primary font-medium capitalize">{notification.title}</span>
|
||||
<span className="text-sm text-fg-secondary/60 capitalize">{notification.message}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="flex-shrink-0 h-6 w-6 p-0"
|
||||
color="ghost"
|
||||
onClick={handleRemove}
|
||||
size="icon"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={notification.fraction !== undefined && !fractionEnded(notification.fraction)}>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-brand-disabled/10">
|
||||
<div
|
||||
className="h-full max-w-full min-w-0 rounded-full bg-brand transition-width"
|
||||
style={{
|
||||
width: `${Math.floor(notification.fraction! * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationToasts() {
|
||||
const { list } = useNotifications();
|
||||
const [toasts, setToasts] = useState<Array<ToastNotification>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentToasts = Object.entries(list).map(([id, data]) => ({
|
||||
id,
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
setToasts((prev) => {
|
||||
const existingIds = new Set(prev.map(t => t.id));
|
||||
const newToasts = currentToasts.filter(toast => !existingIds.has(toast.id));
|
||||
|
||||
const updatedToasts = prev.map((toast) => {
|
||||
const updated = currentToasts.find(t => t.id === toast.id);
|
||||
return updated ? { ...toast, ...updated } : toast;
|
||||
});
|
||||
|
||||
const currentIds = new Set(currentToasts.map(t => t.id));
|
||||
const filteredToasts = updatedToasts.filter(toast => currentIds.has(toast.id));
|
||||
|
||||
return [...filteredToasts, ...newToasts];
|
||||
});
|
||||
}, [list]);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
if (toasts.length === 0)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
{toasts.map(toast => (
|
||||
<NotificationToast
|
||||
key={toast.id}
|
||||
notification={toast}
|
||||
onRemove={removeToast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationToasts;
|
||||
|
|
@ -2,58 +2,61 @@ import type { MinecraftCredentials } from '@/bindings.gen';
|
|||
import PlayerHead from '@/components/content/PlayerHead';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, Menu } from '@onelauncher/common/components';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { PlusIcon, Settings01Icon } from '@untitled-theme/icons-react';
|
||||
import { Separator } from 'react-aria-components';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
function AccountPopup() {
|
||||
const users = useCommand('getUsers', bindings.core.getUsers);
|
||||
const defaultUser = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false));
|
||||
|
||||
const addAccount = useCommand('openMsaLogin', bindings.core.openMsaLogin, {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
const setDefaultUser = (user: MinecraftCredentials) => {
|
||||
bindings.commands.setDefaultUser(user.id);
|
||||
bindings.core.setDefaultUser(user.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu className="min-w-3xs">
|
||||
<div className="min-w-3xs">
|
||||
{defaultUser.data && (
|
||||
<Menu.Item>
|
||||
<div>
|
||||
<AccountEntry
|
||||
loggedIn
|
||||
onClick={() => {}}
|
||||
onClick={() => { }}
|
||||
user={defaultUser.data}
|
||||
/>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{users.data?.filter(user => user.id === defaultUser.data?.id).map(user => (
|
||||
<Menu.Item key={user.id}>
|
||||
{users.data?.filter(user => user.id !== defaultUser.data?.id).map(user => (
|
||||
<div key={user.id}>
|
||||
<AccountEntry
|
||||
onClick={() => setDefaultUser(user)}
|
||||
user={user}
|
||||
/>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(users.data?.length ?? 0) > 0 && (
|
||||
<Separator />
|
||||
)}
|
||||
|
||||
<Menu.Item className="flex flex-row justify-between">
|
||||
<div>
|
||||
<Button color="ghost">
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="self-center">
|
||||
<Button color="ghost" onClick={() => addAccount.refetch()}>
|
||||
<PlusIcon />
|
||||
Add Account
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<Button color="ghost" size="iconLarge">
|
||||
<Settings01Icon className="stroke-fg-primary" />
|
||||
</Button>
|
||||
<Link to="/app/settings/accounts">
|
||||
<Button color="ghost" size="iconLarge">
|
||||
<Settings01Icon className="stroke-fg-primary" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import useNotifications from '@/hooks/useNotification';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { InfoCircleIcon, Trash01Icon } from '@untitled-theme/icons-react';
|
||||
|
||||
function NotificationPopup() {
|
||||
const { list, clear } = useNotifications();
|
||||
const notificationEntries = Object.entries(list);
|
||||
|
||||
if (notificationEntries.length === 0)
|
||||
return (
|
||||
<div className="p-3 w-80 text-center">
|
||||
<p className="text-fg-secondary text-sm">No notifications</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-w-80 w-80 max-w-96 max-h-96 overflow-y-auto">
|
||||
<div className="flex flex-col bg-page-elevated">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-fg-primary ml-2">Notifications</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{notificationEntries.map(([id, data]) => (
|
||||
<div className="flex items-start gap-3 p-2 rounded-lg" key={id}>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<InfoCircleIcon className="h-6 w-6 text-fg-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-fg-primary text-sm">
|
||||
{data.title}
|
||||
</h4>
|
||||
<p className="text-fg-secondary text-xs mt-1">
|
||||
{data.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 mt-2 self-center">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<hr className="mb-2 text-component-border-pressed" />
|
||||
|
||||
{notificationEntries.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
className="text-left"
|
||||
color="ghost"
|
||||
onClick={clear}
|
||||
size="normal"
|
||||
>
|
||||
<Trash01Icon className="h-4 w-4" />
|
||||
Clear Notifications
|
||||
</Button>
|
||||
{/* <Button
|
||||
className="h-6 w-6 p-0"
|
||||
color="ghost"
|
||||
size="iconLarge"
|
||||
>
|
||||
<Settings01Icon />
|
||||
</Button> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationPopup;
|
||||
57
apps/onelauncher/frontend/src/hooks/useBrowser.tsx
Normal file
57
apps/onelauncher/frontend/src/hooks/useBrowser.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { ClusterModel, Provider, SearchQuery } from '@/bindings.gen';
|
||||
import { bindings } from '@/main';
|
||||
import { PROVIDERS } from '@/utils';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
|
||||
export interface BrowserControllerType {
|
||||
cluster: ClusterModel | undefined;
|
||||
setCluster: (cluster: ClusterModel | undefined) => void;
|
||||
provider: Provider;
|
||||
setProvider: (provider: Provider) => void;
|
||||
query: SearchQuery;
|
||||
setQuery: (query: SearchQuery) => void;
|
||||
search: () => void;
|
||||
}
|
||||
|
||||
const BrowserContext = createContext<BrowserControllerType | null>(null);
|
||||
|
||||
export function useBrowserContext() {
|
||||
const context = useContext(BrowserContext);
|
||||
if (!context)
|
||||
throw new Error('useBrowserContext must be used within a BrowserProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
export function BrowserProvider(props: any) {
|
||||
const { children } = props;
|
||||
const [cluster, setCluster] = useState<ClusterModel | undefined>(undefined);
|
||||
const [provider, setProvider] = useState<Provider>(PROVIDERS[0]);
|
||||
const [query, setQuery] = useState<SearchQuery>({
|
||||
filters: null,
|
||||
query: null,
|
||||
limit: 18 as unknown as bigint,
|
||||
offset: null,
|
||||
sort: null,
|
||||
});
|
||||
|
||||
const context = useMemo<BrowserControllerType>(() => ({
|
||||
cluster,
|
||||
setCluster,
|
||||
provider,
|
||||
setProvider,
|
||||
query,
|
||||
setQuery,
|
||||
search: () => {},
|
||||
}), [cluster, provider, query]);
|
||||
|
||||
return (
|
||||
<BrowserContext.Provider value={context}>
|
||||
{children}
|
||||
</BrowserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBrowserSearch(provider: Provider, query: SearchQuery) {
|
||||
return useCommand('searchPackages', () => bindings.core.searchPackages(provider, query));
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import type { Model } from '@/bindings.gen';
|
||||
import type { ClusterModel } from '@/bindings.gen';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function useRecentCluster() {
|
||||
export function useRecentCluster() {
|
||||
const result = useCommand('getClusters', bindings.core.getClusters);
|
||||
const [cluster, setCluster] = useState<Model | undefined>();
|
||||
const [cluster, setCluster] = useState<ClusterModel | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!result.data)
|
||||
return;
|
||||
|
||||
let mostRecentCluster: Model | undefined;
|
||||
let mostRecentCluster: ClusterModel | undefined;
|
||||
|
||||
for (const c of result.data) {
|
||||
if (!mostRecentCluster) {
|
||||
|
|
@ -40,4 +40,15 @@ function useRecentCluster() {
|
|||
return cluster;
|
||||
}
|
||||
|
||||
export default useRecentCluster;
|
||||
export function useClusters() {
|
||||
const result = useCommand('getClusters', bindings.core.getClusters);
|
||||
const [clusters, setClusters] = useState<Array<ClusterModel> | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!result.data)
|
||||
return;
|
||||
setClusters(result.data);
|
||||
}, [result.data]);
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
|
|
|||
123
apps/onelauncher/frontend/src/hooks/useNotification.tsx
Normal file
123
apps/onelauncher/frontend/src/hooks/useNotification.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { bindings } from '@/main';
|
||||
import { randomString } from '@/utils/index';
|
||||
import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface NotificationData {
|
||||
title: string;
|
||||
message: string;
|
||||
fraction?: number | undefined;
|
||||
}
|
||||
|
||||
type Notifications = Record<string, NotificationData>;
|
||||
|
||||
export interface HookReturn {
|
||||
list: Notifications;
|
||||
set: (id: string, data: NotificationData) => void;
|
||||
create: (data: NotificationData) => void;
|
||||
remove: (id: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
interface NotificationContextValue {
|
||||
notifications: Notifications;
|
||||
setNotifications: React.Dispatch<React.SetStateAction<Notifications>>;
|
||||
}
|
||||
|
||||
const defaultContextValue: NotificationContextValue = {
|
||||
notifications: {},
|
||||
setNotifications: () => { },
|
||||
};
|
||||
|
||||
const NotificationContext = createContext<NotificationContextValue>(defaultContextValue);
|
||||
|
||||
interface NotificationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const NotificationProvider = memo(({ children }: NotificationProviderProps) => {
|
||||
const [notifications, setNotifications] = useState<Notifications>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
bindings.events.ingress.on((e) => {
|
||||
if (typeof e.ingress_type === 'object') {
|
||||
if ('PrepareCluster' in e.ingress_type) {
|
||||
const { cluster_name } = e.ingress_type.PrepareCluster;
|
||||
setNotifications(prev => ({
|
||||
...prev,
|
||||
[e.id]: {
|
||||
title: `Preparing ${cluster_name}`,
|
||||
message: e.message,
|
||||
fraction: e.percent ?? undefined,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
else {
|
||||
setNotifications(prev => ({
|
||||
...prev,
|
||||
[e.id]: {
|
||||
title: `${e.ingress_type}`,
|
||||
message: e.message,
|
||||
fraction: e.percent ?? undefined,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}).then((u) => {
|
||||
cleanup = u;
|
||||
}).catch(console.error);
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
notifications,
|
||||
setNotifications,
|
||||
}), [notifications, setNotifications]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
function useNotifications(): HookReturn {
|
||||
const context = useContext(NotificationContext);
|
||||
const { notifications, setNotifications } = context;
|
||||
|
||||
const isDefaultContext = setNotifications === defaultContextValue.setNotifications;
|
||||
|
||||
if (isDefaultContext) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('useNotifications: Using default context (likely due to HMR or missing provider)');
|
||||
return {
|
||||
list: {},
|
||||
set: () => { },
|
||||
create: () => { },
|
||||
remove: () => { },
|
||||
clear: () => { },
|
||||
};
|
||||
}
|
||||
throw new Error('useNotifications must be used within a NotificationProvider');
|
||||
}
|
||||
|
||||
const ctx: HookReturn = {
|
||||
list: notifications,
|
||||
set: (id, data) => setNotifications(prev => ({ ...prev, [id]: data })),
|
||||
create: data => ctx.set(randomString(6), data),
|
||||
remove: id => setNotifications((prev) => {
|
||||
const { [id]: removed, ...rest } = prev;
|
||||
return rest;
|
||||
}),
|
||||
clear: () => setNotifications({}),
|
||||
};
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default useNotifications;
|
||||
20
apps/onelauncher/frontend/src/hooks/usePopState.ts
Normal file
20
apps/onelauncher/frontend/src/hooks/usePopState.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useLocation } from '@tanstack/react-router';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function usePopState(onRouteChange: void | any) {
|
||||
const location = useLocation();
|
||||
const prevLocationRef = useRef(location);
|
||||
|
||||
useEffect(() => {
|
||||
const prevLocation = prevLocationRef.current;
|
||||
|
||||
// Only run if the pathname actually changed
|
||||
if (prevLocation.pathname !== location.pathname)
|
||||
onRouteChange?.({
|
||||
from: prevLocation,
|
||||
to: location,
|
||||
});
|
||||
|
||||
prevLocationRef.current = location;
|
||||
}, [location, onRouteChange]);
|
||||
}
|
||||
133
apps/onelauncher/frontend/src/hooks/useSettings.tsx
Normal file
133
apps/onelauncher/frontend/src/hooks/useSettings.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks -- stfu */
|
||||
/* eslint-disable ts/naming-convention -- please shut the fuck up i need to see if its working or not */
|
||||
import type { Settings } from '@/bindings.gen';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface SettingsControllerType {
|
||||
settings: Settings | undefined;
|
||||
saveChangedSettings: () => void;
|
||||
settingsToSave: Partial<Settings> | null;
|
||||
setSettingsToSave: (settings: Partial<Settings> | null) => void;
|
||||
settingsChanged: boolean;
|
||||
setSettingsChanged: (changed: boolean) => void;
|
||||
createSetting: <K extends keyof Settings, V = Settings[K]>(name: K, value: V) => [V, (value: V) => void];
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsControllerType | null>(null);
|
||||
|
||||
interface SettingsProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
const { data: settings } = useCommand('readSettings', bindings.core.readSettings);
|
||||
const [settingsChanged, setSettingsChanged] = useState(false);
|
||||
const [settingsToSave, setSettingsToSave] = useState<Partial<Settings> | null>(null);
|
||||
|
||||
const filteredSettingsToSave = settingsToSave
|
||||
? Object.fromEntries(
|
||||
Object.entries(settingsToSave),
|
||||
) as Partial<Settings>
|
||||
: {};
|
||||
|
||||
const patchedSettings: Settings = {
|
||||
...settings,
|
||||
...filteredSettingsToSave,
|
||||
} as Settings;
|
||||
|
||||
const writeQuery = useCommand('writeSettings', () => bindings.core.writeSettings(patchedSettings), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
setSettingsChanged(false);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
const saveChangedSettings = () => {
|
||||
if (!settings)
|
||||
return;
|
||||
|
||||
writeQuery.refetch();
|
||||
|
||||
setSettingsChanged(false);
|
||||
};
|
||||
|
||||
const handleSetSettingsToSave = useCallback((newSettings: Partial<Settings> | null) => {
|
||||
setSettingsToSave(newSettings);
|
||||
setSettingsChanged(true);
|
||||
}, []);
|
||||
|
||||
const createSetting = useCallback(<K extends keyof Settings, V = Settings[K]>(
|
||||
name: K,
|
||||
initialValue: V,
|
||||
): [V, (value: V) => void] => {
|
||||
const [value, setValue] = useState<V>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const didChange = settings?.[name] !== value;
|
||||
|
||||
if (didChange && settings) {
|
||||
setSettingsToSave(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
setSettingsChanged(true);
|
||||
}
|
||||
}, [value, name]);
|
||||
|
||||
return [value, setValue];
|
||||
}, [settings]);
|
||||
|
||||
const controller: SettingsControllerType = {
|
||||
settings,
|
||||
saveChangedSettings,
|
||||
settingsToSave,
|
||||
setSettingsToSave: handleSetSettingsToSave,
|
||||
settingsChanged,
|
||||
setSettingsChanged,
|
||||
createSetting,
|
||||
};
|
||||
|
||||
if (settings === undefined)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={controller}>
|
||||
{children}
|
||||
|
||||
{settingsChanged && (
|
||||
<div className="pointer-events-none absolute bottom-5 left-1/2 w-full flex flex-col items-center justify-center -translate-x-1/2">
|
||||
<div className="pointer-events-auto flex flex-row gap-1 border border-component-border rounded-md bg-component-bg p-3 px-4">
|
||||
You have unsaved changes!
|
||||
<button
|
||||
className="text-brand filter-brightness-130 hover:underline"
|
||||
onClick={saveChangedSettings}
|
||||
type="button"
|
||||
>
|
||||
Save Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettings(): SettingsControllerType {
|
||||
const context = useContext(SettingsContext);
|
||||
|
||||
if (!context)
|
||||
throw new Error('useSettings should be called inside its SettingsProvider');
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export default useSettings;
|
||||
|
|
@ -4,8 +4,10 @@ import { createRouter, RouterProvider } from '@tanstack/react-router';
|
|||
import { StrictMode } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { createTauRPCProxy } from './bindings.gen';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
import { NotificationProvider } from './hooks/useNotification';
|
||||
|
||||
import { SettingsProvider } from './hooks/useSettings';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
import './fonts';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import './styles/global.css';
|
||||
|
|
@ -55,7 +57,11 @@ if (rootElement && !rootElement.innerHTML) {
|
|||
root.render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<SettingsProvider>
|
||||
<NotificationProvider>
|
||||
<RouterProvider router={router} />
|
||||
</NotificationProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import type { NavigateOptions, ToOptions } from '@tanstack/react-router';
|
||||
import NotificationToasts from '@/components/overlay/NotificationToasts';
|
||||
import WindowFrame from '@/components/WindowFrame';
|
||||
import { AnimatedOutletProvider } from '@onelauncher/common/components';
|
||||
import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router';
|
||||
|
|
@ -34,6 +35,7 @@ function RootRoute() {
|
|||
|
||||
<Outlet />
|
||||
|
||||
<NotificationToasts />
|
||||
<TanStackRouterDevtools />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import Modal from '@/components/overlay/Modal';
|
||||
import { Button, Dropdown, TextField } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import OneConfigLogo from '@/assets/logos/oneconfig.svg';
|
||||
import { useBrowserContext, useBrowserSearch } from '@/hooks/useBrowser';
|
||||
import { Button, Show } from '@onelauncher/common/components';
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { ChevronRightIcon } from '@untitled-theme/icons-react';
|
||||
import { BrowserLayout } from './route';
|
||||
|
||||
export const Route = createFileRoute('/app/browser/')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -8,24 +11,52 @@ export const Route = createFileRoute('/app/browser/')({
|
|||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div>
|
||||
<p>asdasdas</p>
|
||||
|
||||
<TextField />
|
||||
|
||||
{/* we can open the modal but we cant close it for some reason */}
|
||||
<Modal.Trigger>
|
||||
<Button>Open Modal</Button>
|
||||
|
||||
<Modal>
|
||||
<p>sadsadsad</p>
|
||||
</Modal>
|
||||
</Modal.Trigger>
|
||||
|
||||
<Dropdown label="Select a version">
|
||||
<Dropdown.Item>Slmalr</Dropdown.Item>
|
||||
<Dropdown.Item>Slmalr 2</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<BrowserLayout>
|
||||
<div className="flex flex-col gap-8">
|
||||
<Featured />
|
||||
</div>
|
||||
</BrowserLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Featured() {
|
||||
const context = useBrowserContext();
|
||||
const search = useBrowserSearch(context.provider, context.query);
|
||||
return (
|
||||
<Show when>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h5 className="ml-2">Featured</h5>
|
||||
<div className="w-full flex flex-row overflow-hidden rounded-lg bg-page-elevated">
|
||||
<div className="w-full p-1">
|
||||
<img alt="thumbnail" className="aspect-ratio-video h-full rounded-md object-cover object-center" src="https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp" />
|
||||
</div>
|
||||
<div className="max-w-84 min-w-52 flex flex-col gap-y-1 p-4">
|
||||
<h2>Sodium</h2>
|
||||
|
||||
<Show when={false}>
|
||||
<div className="w-fit flex flex-row items-center gap-x-1 rounded-lg bg-border/10 px-1.5 py-1 text-fg-primary transition hover:opacity-80">
|
||||
<img alt="OneConfig Logo" className="h-3.5 w-3.5" src={OneConfigLogo} />
|
||||
<span className="text-sm font-medium">OneConfig Integrated</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<p className="mt-1 flex-1 leading-normal">Peak mod</p>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button color="ghost">
|
||||
View Package
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{search.status}
|
||||
{search.isSuccess && (
|
||||
<>
|
||||
{search.data.items.map(item => <p key={item.id}>{item.name}</p>)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
146
apps/onelauncher/frontend/src/routes/app/browser/route.tsx
Normal file
146
apps/onelauncher/frontend/src/routes/app/browser/route.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { Filters } from '@/bindings.gen';
|
||||
import ProviderIcon from '@/components/content/ProviderIcon';
|
||||
import { BrowserProvider, useBrowserContext } from '@/hooks/useBrowser';
|
||||
import { useClusters } from '@/hooks/useCluster';
|
||||
import { PROVIDERS } from '@/utils';
|
||||
import { browserCategories } from '@/utils/browser';
|
||||
import { Dropdown, Show, TextField } from '@onelauncher/common/components';
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { SearchMdIcon } from '@untitled-theme/icons-react';
|
||||
|
||||
export const Route = createFileRoute('/app/browser')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<BrowserProvider>
|
||||
<div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</BrowserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowserLayout(props: any) {
|
||||
return (
|
||||
<div className="relative h-full flex flex-1 flex-col items-center gap-2">
|
||||
<div className="h-full w-full max-w-screen-xl flex flex-1 flex-col items-center gap-y-2">
|
||||
<div className="grid grid-cols-[220px_auto_220px] w-full gap-x-6">
|
||||
<div />
|
||||
<BrowserToolbar />
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[220px_auto_220px] w-full max-w-screen-xl gap-x-6 pb-8">
|
||||
<BrowserCategories />
|
||||
|
||||
<div className="h-full flex flex-col gap-y-4">
|
||||
<div className="h-full flex-1">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrowserSidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserSidebar() {
|
||||
const context = useBrowserContext();
|
||||
const clusters = useClusters();
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h6 className="my-1">Active Cluster</h6>
|
||||
<Dropdown
|
||||
onSelectionChange={(id) => {
|
||||
const cluster = clusters?.find(cluster => cluster.id.toString() === id);
|
||||
context.setCluster(cluster);
|
||||
}}
|
||||
selectedKey={context.cluster?.id.toString()}
|
||||
>
|
||||
{clusters?.map(cluster => (
|
||||
<Dropdown.Item id={cluster.id.toString()} key={cluster.id}>
|
||||
{cluster.name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h6 className="my-1">Provider</h6>
|
||||
<Dropdown onSelectionChange={id => context.setProvider(id as typeof context.provider)} selectedKey={context.provider}>
|
||||
{PROVIDERS.map(provider => (
|
||||
<Dropdown.Item id={provider} key={provider}>
|
||||
<div className="flex flex-row">
|
||||
<ProviderIcon className="size-4 mr-2 self-center" provider={provider} />
|
||||
{provider}
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultFilters:Filters = {
|
||||
categories: null,
|
||||
game_versions: null,
|
||||
loaders: null,
|
||||
package_types: null
|
||||
}
|
||||
|
||||
function BrowserCategories() {
|
||||
const context = useBrowserContext();
|
||||
const categories = browserCategories.byPackageType('mod', context.provider);
|
||||
|
||||
function switchCategory(category:string){
|
||||
if(context.query.filters?.categories?.includes(category))
|
||||
context.setQuery({...context.query, filters: {...defaultFilters, ...context.query.filters, categories: (context.query.filters?.categories ?? []).filter(cat=>cat!==category)}})
|
||||
else
|
||||
context.setQuery({...context.query, filters: {...defaultFilters, ...context.query.filters, categories: [...(context.query.filters?.categories ?? []), category]}})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="top-0 grid grid-cols-[1fr_auto] h-fit min-w-50 gap-y-6">
|
||||
<div />
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<Show when>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h6 className="my-1 uppercase">Categories</h6>
|
||||
{categories.map(category => (
|
||||
<p
|
||||
aria-selected={context.query.filters?.categories?.includes(category.id)}
|
||||
className="text-md capitalize opacity-60 hover:opacity-90 text-fg-primary hover:text-fg-primary-hover aria-selected:opacity-100"
|
||||
key={category.id}
|
||||
onClick={()=>switchCategory(category.id)}
|
||||
>
|
||||
{category.display}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserToolbar() {
|
||||
return (
|
||||
<div className="w-full flex flex-row justify-between bg-page">
|
||||
<div className="flex flex-row gap-2" />
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<TextField
|
||||
iconLeft={<SearchMdIcon />}
|
||||
placeholder="Search for content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,21 @@
|
|||
import type { Model } from '@/bindings.gen';
|
||||
import type { ClusterModel } from '@/bindings.gen';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import DefaultInstancePhoto from '@/assets/images/default_instance_cover.jpg';
|
||||
import LoaderIcon from '@/components/launcher/LoaderIcon';
|
||||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import SettingsRow from '@/components/SettingsRow';
|
||||
import useNotifications from '@/hooks/useNotification';
|
||||
import { bindings } from '@/main';
|
||||
import { formatAsDuration } from '@/utils';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, Show, TextField } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { dataDir, join } from '@tauri-apps/api/path';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { openPath } from '@tauri-apps/plugin-opener';
|
||||
import { Edit02Icon, FolderIcon, ImagePlusIcon, Share07Icon, Tool02Icon, Trash01Icon } from '@untitled-theme/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Sidebar from '../settings/route';
|
||||
|
||||
|
|
@ -18,13 +25,68 @@ export const Route = createFileRoute('/app/cluster/')({
|
|||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useSearch();
|
||||
const [newCover, setNewCover] = useState<string>('');
|
||||
const [newName, setNewName] = useState<string>('');
|
||||
const [edit, setEdit] = useState<boolean>(false);
|
||||
|
||||
// dumbass fix ik
|
||||
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
|
||||
|
||||
// dumbass fix ik
|
||||
const [newCover, setNewCover] = useState<string>(cluster.data?.icon_url as string);
|
||||
const [newName, setNewName] = useState<string>(cluster.data?.name as string);
|
||||
const [edit, setEdit] = useState<boolean>(false);
|
||||
|
||||
const editing = useCommand('updateClusterById', () => bindings.core.updateClusterById(Number(id.toString()) as unknown as bigint, {
|
||||
icon_url: newCover,
|
||||
name: newName,
|
||||
}), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
const [launcherDir, setLauncherDir] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLauncherDir(await join(await dataDir(), 'OneLauncher', 'clusters', cluster.data?.folder_name as string));
|
||||
})();
|
||||
}, [cluster.data?.folder_name]);
|
||||
|
||||
const openClusterDir = async () => {
|
||||
openPath(launcherDir);
|
||||
};
|
||||
|
||||
async function handleEditMode() {
|
||||
if (edit)
|
||||
await editing.refetch();
|
||||
|
||||
setEdit(!edit);
|
||||
}
|
||||
/*
|
||||
const { set, remove } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
unlisten = await bindings.events.ingress.on((e) => {
|
||||
if (typeof e.ingress_type === 'object')
|
||||
if ('PrepareCluster' in e.ingress_type) {
|
||||
const { cluster_name } = e.ingress_type.PrepareCluster;
|
||||
let cid = `cluster-${cluster_name}`;
|
||||
|
||||
if (e.percent === null) {
|
||||
remove(cid);
|
||||
return;
|
||||
}
|
||||
|
||||
set(cid, {
|
||||
title: 'Preparing Cluster',
|
||||
message: e.message,
|
||||
fraction: e.percent,
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
return () => unlisten?.();
|
||||
}, [set, remove]);
|
||||
*/
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<h1>Overview</h1>
|
||||
|
|
@ -47,9 +109,10 @@ function RouteComponent() {
|
|||
children="Open"
|
||||
color="primary"
|
||||
isDisabled={false}
|
||||
onClick={openClusterDir}
|
||||
/>
|
||||
)}
|
||||
description="burada path var işte yersen"
|
||||
description={launcherDir}
|
||||
disabled={false}
|
||||
icon={<FolderIcon />}
|
||||
title="Cluster Folder"
|
||||
|
|
@ -58,7 +121,7 @@ function RouteComponent() {
|
|||
<SettingsRow.Header>Cluster Actions</SettingsRow.Header>
|
||||
<SettingsRow
|
||||
children={(
|
||||
<Button color="secondary" onClick={() => setEdit(!edit)}>{edit ? 'Save' : 'Edit'}</Button>
|
||||
<Button color="secondary" onClick={handleEditMode}>{edit ? 'Save' : 'Edit'}</Button>
|
||||
)}
|
||||
description="Edit the cluster name and cover image."
|
||||
icon={<Edit02Icon />}
|
||||
|
|
@ -95,7 +158,7 @@ function RouteComponent() {
|
|||
}
|
||||
|
||||
interface BannerProps {
|
||||
cluster: Model | null | undefined;
|
||||
cluster: ClusterModel | null | undefined;
|
||||
editMode: boolean;
|
||||
newName: string;
|
||||
setNewName: Dispatch<SetStateAction<string>>;
|
||||
|
|
@ -107,23 +170,28 @@ interface BannerProps {
|
|||
function Banner({
|
||||
cluster,
|
||||
editMode,
|
||||
newCover,
|
||||
setNewName,
|
||||
setNewCover,
|
||||
}: BannerProps) {
|
||||
// async function launchFilePicker() {
|
||||
// const selected = await dialog.open({
|
||||
// multiple: false,
|
||||
// directory: false,
|
||||
// filters: [{
|
||||
// name: 'Image',
|
||||
// extensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
// }],
|
||||
// });
|
||||
const { set } = useNotifications();
|
||||
|
||||
// if (selected === null)
|
||||
// return;
|
||||
async function launchFilePicker() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [{
|
||||
name: 'Image',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
}],
|
||||
});
|
||||
|
||||
if (selected === null)
|
||||
return;
|
||||
|
||||
setNewCover(selected);
|
||||
}
|
||||
|
||||
// props.setNewCover(selected);
|
||||
// }
|
||||
const launch = useCommand('launchCluster', () => bindings.core.launchCluster(cluster?.id as bigint, null), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
|
|
@ -132,8 +200,11 @@ function Banner({
|
|||
const handleLaunch = () => {
|
||||
launch.refetch();
|
||||
|
||||
if (launch.error)
|
||||
console.error(launch.error.message);
|
||||
if (launch.isError)
|
||||
set('launch_cluster', {
|
||||
title: 'Failed to launch cluster',
|
||||
message: launch.error.message,
|
||||
});
|
||||
};
|
||||
|
||||
function updateName(name: string) {
|
||||
|
|
@ -143,20 +214,42 @@ function Banner({
|
|||
setNewName(name);
|
||||
}
|
||||
|
||||
const image = () => {
|
||||
if (editMode && newCover && newCover !== cluster?.icon_url)
|
||||
if (newCover.includes('\\') || newCover.includes('/')) {
|
||||
console.warn('Using preview image:', newCover);
|
||||
return convertFileSrc(newCover);
|
||||
}
|
||||
|
||||
const url = cluster?.icon_url;
|
||||
|
||||
if (!url)
|
||||
return DefaultInstancePhoto;
|
||||
|
||||
return convertFileSrc(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-37 flex flex-row gap-x-2.5 rounded-xl bg-page-elevated p-2.5">
|
||||
<div className="relative aspect-ratio-video h-full min-w-57 w-57 overflow-hidden border border-component-bg/10 rounded-lg">
|
||||
<Show when={editMode}>
|
||||
<div
|
||||
className="absolute z-1 h-full w-full flex items-center justify-center bg-black/50 opacity-50 hover:opacity-100"
|
||||
// onClick={launchFilePicker}
|
||||
onClick={launchFilePicker}
|
||||
>
|
||||
<ImagePlusIcon className="h-12 w-12" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* <ClusterCover class="h-full w-full object-cover" cluster={props.cluster} override={props.newCover()} /> */}
|
||||
<img src="https://github.com/emirsassan.png" />
|
||||
<img
|
||||
className="h-full w-full"
|
||||
onError={(e) => {
|
||||
console.error('Failed to load cluster icon:', image(), e);
|
||||
(e.target as HTMLImageElement).src = DefaultInstancePhoto;
|
||||
}}
|
||||
src={image()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col justify-between gap-y-.5 overflow-hidden text-fg-primary">
|
||||
|
|
@ -193,7 +286,7 @@ function Banner({
|
|||
>
|
||||
Played for
|
||||
{' '}
|
||||
<b>{cluster?.overall_played || 0}</b>
|
||||
<b>{formatAsDuration(cluster?.overall_played || 0)}</b>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -204,8 +297,7 @@ function Banner({
|
|||
<Button
|
||||
children={<Share07Icon />}
|
||||
color="secondary"
|
||||
// disabled={props.editMode()}
|
||||
isDisabled
|
||||
isDisabled={editMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
223
apps/onelauncher/frontend/src/routes/app/cluster/logs.tsx
Normal file
223
apps/onelauncher/frontend/src/routes/app/cluster/logs.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import type { ProcessPayload } from '@/bindings.gen';
|
||||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, Show } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { dataDir, join } from '@tauri-apps/api/path';
|
||||
import { openPath } from '@tauri-apps/plugin-opener';
|
||||
import { LinkExternal01Icon, Upload01Icon } from '@untitled-theme/icons-react';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { useEffect } from 'react';
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
import Sidebar from '../settings/route';
|
||||
|
||||
export const Route = createFileRoute('/app/cluster/logs')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const store = proxy({
|
||||
logs: [] as Array<ProcessPayload>,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id: _id } = Route.useSearch();
|
||||
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(_id.toString()) as unknown as bigint));
|
||||
const state = useSnapshot(store);
|
||||
|
||||
const addLog = (newLog: ProcessPayload) => {
|
||||
store.logs.push(newLog);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
bindings.events.process.on(addLog).then((e) => {
|
||||
cleanup = e;
|
||||
}).catch(console.error);
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openLogsFolder = async () => {
|
||||
openPath(await join(await dataDir(), 'OneLauncher', 'clusters', cluster.data?.folder_name as string, 'logs'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<ScrollableContainer>
|
||||
<div className="h-full">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col gap-y-2">
|
||||
<div className="h-10 flex flex-row items-center justify-between gap-x-1">
|
||||
<h1>Logs</h1>
|
||||
<div className="flex flex-row gap-x-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
// isDisabled={missingLogs()}
|
||||
// onClick={uploadAndOpenLog}
|
||||
>
|
||||
<Upload01Icon />
|
||||
{' '}
|
||||
Upload
|
||||
</Button>
|
||||
|
||||
{/* <Dropdown
|
||||
class="min-w-50"
|
||||
disabled={missingLogs()}
|
||||
onChange={changeLog}
|
||||
>
|
||||
<For each={logs() || ['None']}>
|
||||
{(log) => {
|
||||
const pretty = log.split('/').pop();
|
||||
return (
|
||||
<Dropdown.Row>
|
||||
<div>
|
||||
{pretty}
|
||||
</div>
|
||||
</Dropdown.Row>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Dropdown> */}
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={openLogsFolder}
|
||||
>
|
||||
<LinkExternal01Icon />
|
||||
{' '}
|
||||
Open Folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
fallback={<span>No logs were found.</span>}
|
||||
when={state.logs.length > 0}
|
||||
>
|
||||
<FormattedLog log={state.logs} />
|
||||
{/* {JSON.stringify(state.logs)} */}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
</Sidebar.Page>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormattedLogProps {
|
||||
log: ReadonlyArray<ProcessPayload>;
|
||||
}
|
||||
|
||||
function FormattedLog(props: FormattedLogProps) {
|
||||
const { log } = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-1 flex-col overflow-hidden rounded-lg bg-component-bg border border-component-border shadow-sm">
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-component-border to-transparent" />
|
||||
|
||||
<div className="h-full flex flex-1 overflow-auto font-medium font-mono text-sm">
|
||||
<OverlayScrollbarsComponent
|
||||
className="relative h-full flex-1"
|
||||
>
|
||||
<code className="log-container whitespace-pre flex flex-col text-sm p-2 gap-0.5">
|
||||
{log.map((logLine, index) => {
|
||||
if (index === log.length - 1)
|
||||
return null;
|
||||
|
||||
// trickery fuckery
|
||||
const lineKey = `${logLine.type}-${index}-${(logLine.type === 'Output' ? logLine.output : '').slice(0, 50)}`;
|
||||
return <Line key={lineKey} line={logLine} />;
|
||||
})}
|
||||
</code>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const REGEX_PATTERN = /\[(\d+:\d+:\d+)\] \[(.[^(\n\r/\u2028\u2029]*)\/(\w+)\]:? (?:\[(CHAT)\])?/;
|
||||
|
||||
export function Line(props: { line: ProcessPayload }) {
|
||||
const { line } = props;
|
||||
|
||||
const format = (logText: string) => {
|
||||
let processedLine = logText.replace(/§./g, '');
|
||||
const prefix = processedLine.match(REGEX_PATTERN);
|
||||
|
||||
const isEmpty = processedLine.trim() === '';
|
||||
|
||||
if (isEmpty)
|
||||
processedLine = '\n';
|
||||
|
||||
if (prefix === null)
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
block py-0.5 rounded-sm hover:bg-component-bg-hover transition-colors duration-150 select-text
|
||||
text-fg-secondary leading-relaxed
|
||||
${isEmpty ? 'h-5 hover:bg-transparent' : ''}
|
||||
`}
|
||||
>
|
||||
{processedLine}
|
||||
</span>
|
||||
);
|
||||
|
||||
const isChatMsg = prefix[4] === 'CHAT';
|
||||
const logLevel = prefix[3].toUpperCase();
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'INFO':
|
||||
return 'text-code-info';
|
||||
case 'DEBUG':
|
||||
return 'text-code-debug';
|
||||
case 'TRACE':
|
||||
return 'text-code-trace';
|
||||
case 'WARN':
|
||||
return 'text-code-warn';
|
||||
case 'ERROR':
|
||||
return 'text-code-error';
|
||||
default:
|
||||
return 'text-fg-primary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
block py-0.5 rounded-sm hover:bg-component-bg-hover transition-colors duration-150 select-text
|
||||
${getLevelColor(logLevel)} leading-relaxed
|
||||
${isEmpty ? 'h-5 hover:bg-transparent' : ''}
|
||||
`}
|
||||
data-level={logLevel}
|
||||
{...(isChatMsg ? { 'data-chat': 'true' } : {})}
|
||||
{...(isEmpty ? { 'data-empty': 'true' } : {})}
|
||||
>
|
||||
<span className="text-fg-secondary opacity-60 font-medium text-xs mr-2 inline-block min-w-[4.5rem] text-right">
|
||||
{prefix[1]}
|
||||
</span>
|
||||
<span className="font-semibold opacity-90 text-xs mr-2">
|
||||
{`[${prefix[2]}/${prefix[3]}]`}
|
||||
</span>
|
||||
{isChatMsg && (
|
||||
<span className="bg-brand text-white px-1.5 py-0.5 rounded text-xs font-medium mr-2">
|
||||
CHAT
|
||||
</span>
|
||||
)}
|
||||
<span className={`${isChatMsg ? 'text-white font-medium' : ''} break-words`}>
|
||||
{processedLine.slice(prefix[0].length)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>{format(line.type === 'Output' ? line.output : '')}</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Show } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { dataDir, join } from '@tauri-apps/api/path';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Sidebar from '../settings/route';
|
||||
|
||||
export const Route = createFileRoute('/app/cluster/screenshots')({
|
||||
|
|
@ -8,7 +13,10 @@ export const Route = createFileRoute('/app/cluster/screenshots')({
|
|||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const arr = [1, 2, 3, 4, 5, 6];
|
||||
const { id } = Route.useSearch();
|
||||
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
|
||||
const result = useCommand('getScreenshots', () => bindings.core.getScreenshots(Number(id.toString()) as unknown as bigint));
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<h1>Screenshots</h1>
|
||||
|
|
@ -16,11 +24,12 @@ function RouteComponent() {
|
|||
<div className="h-full">
|
||||
<Show
|
||||
fallback={<div className="text-border/400">No screenshots found. Press F2 in game to take a screenshot!</div>}
|
||||
when
|
||||
// @ts-ignore idk
|
||||
when={result.data?.length > 0}
|
||||
>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(350px,1fr))] w-full transform-gpu gap-2">
|
||||
{arr.map(data => (
|
||||
<ScreenshotEntry cluster_path="cluster" key={data} path={`screenshot-${data}.png`} />
|
||||
{result.data?.map(data => (
|
||||
<ScreenshotEntry cluster_path={cluster.data?.folder_name || ''} key={data} path={data} />
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -32,20 +41,34 @@ function RouteComponent() {
|
|||
|
||||
function ScreenshotEntry({
|
||||
path,
|
||||
cluster_path,
|
||||
}: {
|
||||
path: string;
|
||||
cluster_path: string;
|
||||
}) {
|
||||
function onClick() {
|
||||
// screen shot viewer overlay
|
||||
}
|
||||
const [imagePath, setImagePath] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadImagePath() {
|
||||
const screenshotsDir = await join(await dataDir(), 'OneLauncher', 'clusters', cluster_path, 'screenshots');
|
||||
const fullPath = await join(screenshotsDir, path);
|
||||
setImagePath(convertFileSrc(fullPath));
|
||||
}
|
||||
|
||||
loadImagePath().catch(console.error);
|
||||
}, [cluster_path, path]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover hover:opacity-80"
|
||||
onClick={onClick}
|
||||
>
|
||||
<img alt={path} className="aspect-video w-full rounded-lg" src="https://github.com/emirsassan.png" />
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover hover:opacity-80 cursor-pointer"
|
||||
>
|
||||
<img
|
||||
alt={path}
|
||||
className="aspect-video w-full rounded-lg"
|
||||
src={imagePath}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import usePopState from '@/hooks/usePopState';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
|
@ -13,19 +14,31 @@ function RouteComponent() {
|
|||
const { id } = Route.useSearch();
|
||||
|
||||
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
|
||||
const _result = useCommand('getProfileOrDefault', () => bindings.core.getProfileOrDefault(cluster.data?.name as string), {
|
||||
enabled: !!cluster.data?.name,
|
||||
const _result = useCommand('getProfileOrDefault', () => bindings.core.getProfileOrDefault(cluster.data?.setting_profile_name as string), {
|
||||
enabled: !!cluster.data?.setting_profile_name,
|
||||
});
|
||||
|
||||
const save = useCommand('updateClusterProfile', () => bindings.core.updateClusterProfile(cluster.data?.name as string, _result.data!), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
usePopState(() => {
|
||||
save.refetch();
|
||||
});
|
||||
|
||||
if (_result.isPending)
|
||||
return <p>loading...</p>;
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<ScrollableContainer>
|
||||
<div className="h-full">
|
||||
<h1>Minecraft Settings</h1>
|
||||
|
||||
<GameSettings />
|
||||
<GameSettings key={_result.data?.name} settings={_result.data!} />
|
||||
|
||||
<ProcessSettings />
|
||||
<ProcessSettings key={_result.data?.name} settings={_result.data!} />
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
</Sidebar.Page>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import WorldIcon from '@/components/content/WorldIcon';
|
||||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, Show } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { Trash01Icon } from '@untitled-theme/icons-react';
|
||||
import Sidebar from '../settings/route';
|
||||
|
||||
export const Route = createFileRoute('/app/cluster/worlds')({
|
||||
|
|
@ -7,14 +12,52 @@ export const Route = createFileRoute('/app/cluster/worlds')({
|
|||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useSearch();
|
||||
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
|
||||
const list = useCommand('getWorlds', () => bindings.core.getWorlds(Number(id.toString()) as unknown as bigint));
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<h1>Worlds</h1>
|
||||
<ScrollableContainer>
|
||||
<div className="h-full">
|
||||
|
||||
<Show
|
||||
fallback={<div className="text-border/400">No worlds found.</div>}
|
||||
when={list.data !== undefined && list.data.length > 0}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{list.data?.map(data => (
|
||||
<WorldEntry cluster_path={cluster.data?.folder_name || ''} key={data} name={data} />
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
</Sidebar.Page>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldEntry(props: { name: string; cluster_path: string }) {
|
||||
const { name, cluster_path } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center justify-between gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-x-3">
|
||||
<WorldIcon className="aspect-ratio-square h-16 w-16" cluster_name={cluster_path} world_name={name} />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h3>{name}</h3>
|
||||
<p>Todo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-end gap-x-3">
|
||||
<Button
|
||||
children={<Trash01Icon />}
|
||||
color="danger"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
110
apps/onelauncher/frontend/src/routes/app/dev.tsx
Normal file
110
apps/onelauncher/frontend/src/routes/app/dev.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import Modal from '@/components/overlay/Modal';
|
||||
import useNotifications from '@/hooks/useNotification';
|
||||
import useSettings from '@/hooks/useSettings';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, Dropdown, Switch, TextField } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/app/dev')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { create, list, clear } = useNotifications();
|
||||
const { settings, createSetting } = useSettings();
|
||||
const result = useCommand('read_settings', bindings.core.readSettings);
|
||||
|
||||
const addSimpleNotif = () => {
|
||||
create({
|
||||
message: 'This is a simple notification message',
|
||||
title: 'Simple Notification',
|
||||
});
|
||||
};
|
||||
|
||||
const addProgressNotif = () => {
|
||||
create({
|
||||
message: 'Download in progress...',
|
||||
title: 'Downloading File',
|
||||
fraction: Math.floor(Math.random() * 100),
|
||||
});
|
||||
};
|
||||
|
||||
const addErrorNotif = () => {
|
||||
create({
|
||||
message: 'Something went wrong while processing your request',
|
||||
title: 'Error',
|
||||
});
|
||||
};
|
||||
|
||||
const [parallel, setParallel] = createSetting('allow_parallel_running_clusters', settings?.allow_parallel_running_clusters);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>this is really became my test place fr</p>
|
||||
|
||||
<TextField />
|
||||
|
||||
{/* we can open the modal but we cant close it for some reason */}
|
||||
<Modal.Trigger>
|
||||
<Button>Open Modal</Button>
|
||||
|
||||
<Modal>
|
||||
<p>sadsadsad</p>
|
||||
</Modal>
|
||||
</Modal.Trigger>
|
||||
|
||||
<div className="max-w-96">
|
||||
<Dropdown label="Select a version">
|
||||
<Dropdown.Item>Slmalr</Dropdown.Item>
|
||||
<Dropdown.Item>Slmalr 2</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={addSimpleNotif}>Add Simple Notification</Button>
|
||||
<Button onClick={addProgressNotif}>Add Progress Notification</Button>
|
||||
<Button onClick={addErrorNotif}>Add Error Notification</Button>
|
||||
<Button onClick={clear}>Clear All Notifications</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="font-semibold mb-2">
|
||||
Current Notifications (
|
||||
{Object.keys(list).length}
|
||||
):
|
||||
</h3>
|
||||
<pre className="text-xs bg-component-bg-hover p-2 rounded overflow-auto max-h-40">
|
||||
{JSON.stringify(list, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row">
|
||||
<div className="w-1/2">
|
||||
<div className="mt-4">
|
||||
<h3>Settings</h3>
|
||||
<pre className="text-xs bg-component-bg-hover p-2 rounded overflow-auto max-h-40">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3>Settings again but this time its from useSettings hook</h3>
|
||||
<pre className="text-xs bg-component-bg-hover p-2 rounded overflow-auto max-h-40">
|
||||
{JSON.stringify(settings, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2">
|
||||
<div className="ml-2">
|
||||
<pre>
|
||||
{parallel ? 'Parallel running is enabled' : 'Parallel running is disabled'}
|
||||
</pre>
|
||||
<Switch defaultSelected={parallel} onChange={val => setParallel(val)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
import type { Model } from '@/bindings.gen';
|
||||
import type { ClusterModel } from '@/bindings.gen';
|
||||
import DefaultBanner from '@/assets/images/default_banner.png';
|
||||
import DefaultInstancePhoto from '@/assets/images/default_instance_cover.jpg';
|
||||
import { NewClusterCreate } from '@/components/launcher/cluster/ClusterCreation';
|
||||
import { NewClusterCreate } from '@/components/launcher/cluster/ClusterCreation';
|
||||
import Modal from '@/components/overlay/Modal';
|
||||
import useRecentCluster from '@/hooks/useCluster';
|
||||
import { useRecentCluster } from '@/hooks/useCluster';
|
||||
import { bindings } from '@/main';
|
||||
import { upperFirst } from '@/utils';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, Show, TextField } from '@onelauncher/common/components';
|
||||
import { formatAsDuration, upperFirst } from '@/utils';
|
||||
import { useCommand, useCommandMut, useCommandSuspense } from '@onelauncher/common';
|
||||
import { Button, Show } from '@onelauncher/common/components';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { PlayIcon, Server01Icon } from '@untitled-theme/icons-react';
|
||||
import { PlayIcon, Server01Icon } from '@untitled-theme/icons-react';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { PlayIcon } from '@untitled-theme/icons-react';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
|
||||
export const Route = createFileRoute('/app/')({
|
||||
|
|
@ -58,32 +56,46 @@ function Banner() {
|
|||
// const launch = useLaunchCluster(() => cluster()?.uuid);
|
||||
// const navigate = useNavigate();
|
||||
|
||||
const image = () => {
|
||||
const url = cluster?.icon_url;
|
||||
|
||||
if (!url)
|
||||
return DefaultBanner;
|
||||
|
||||
return convertFileSrc(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-52 min-h-52 w-full overflow-hidden rounded-xl border border-component-border">
|
||||
<div className="absolute h-52 min-h-52 w-full">
|
||||
<img
|
||||
alt="Default Banner"
|
||||
className="top-0 left-0 h-full rounded-xl w-full object-cover"
|
||||
src={cluster?.icon_url || DefaultBanner}
|
||||
/>
|
||||
<div className="linearBlur after:right-1/3">
|
||||
<img
|
||||
alt="Default Banner"
|
||||
className="top-0 left-0 h-full rounded-xl w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = DefaultBanner;
|
||||
}}
|
||||
src={image()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 h-full flex flex-col items-start justify-between px-8 py-6">
|
||||
<div className="theme-OneLauncher-Dark flex flex-col gap-y-2 text-fg-primary">
|
||||
<h1 className="text-2xl font-medium text-shadow-black text-shadow-2xs">Create a cluster</h1>
|
||||
<h1 className="text-2xl font-medium text-shadow-black text-shadow-2xs">{cluster?.name || 'Create a cluster'}</h1>
|
||||
<Show when={cluster !== undefined}>
|
||||
<p>
|
||||
You've played
|
||||
{' '}
|
||||
<strong>
|
||||
{cluster?.name}
|
||||
{cluster?.mc_version}
|
||||
{' '}
|
||||
{upperFirst(cluster?.mc_loader || 'Unknown')}
|
||||
</strong>
|
||||
{' '}
|
||||
for
|
||||
{' '}
|
||||
<strong>{cluster?.overall_played || 0}</strong>
|
||||
<strong>{formatAsDuration(cluster?.overall_played || 0)}</strong>
|
||||
.
|
||||
</p>
|
||||
</Show>
|
||||
|
|
@ -117,7 +129,7 @@ function Banner() {
|
|||
}
|
||||
|
||||
interface ClusterGroupProps {
|
||||
clusters: Array<Model> | undefined;
|
||||
clusters: Array<ClusterModel> | undefined;
|
||||
isFetching?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -152,37 +164,35 @@ function ClusterCard({
|
|||
name,
|
||||
mc_loader,
|
||||
mc_version,
|
||||
}: Model) {
|
||||
const launch = useCommand('launchCluster', () => bindings.core.launchCluster(id, null), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
icon_url,
|
||||
stage,
|
||||
}: ClusterModel) {
|
||||
const launch = useCommandMut(() => bindings.core.launchCluster(id, null));
|
||||
|
||||
const handleLaunch = () => {
|
||||
launch.refetch();
|
||||
launch.mutate();
|
||||
|
||||
if (launch.error)
|
||||
console.error(launch.error.message);
|
||||
};
|
||||
|
||||
const launch = useCommand('launchCluster', () => bindings.core.launchCluster(id, null), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
const image = () => {
|
||||
const url = icon_url;
|
||||
|
||||
const handleLaunch = () => {
|
||||
launch.refetch();
|
||||
if (!url)
|
||||
return DefaultInstancePhoto;
|
||||
|
||||
if (launch.error)
|
||||
console.error(launch.error.message);
|
||||
return convertFileSrc(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
disabled={stage === 'downloading'}
|
||||
search={{
|
||||
id,
|
||||
}} to="/app/cluster"
|
||||
}}
|
||||
to="/app/cluster"
|
||||
>
|
||||
<div
|
||||
className="group relative h-[152px] flex flex-col rounded-xl border border-component-border/5 bg-component-bg active:bg-component-bg-pressed hover:bg-component-bg-hover"
|
||||
|
|
@ -193,48 +203,22 @@ function ClusterCard({
|
|||
>
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={DefaultInstancePhoto}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = DefaultInstancePhoto;
|
||||
}}
|
||||
src={image()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 flex flex-row items-center justify-between gap-x-3 p-3">
|
||||
<div className="h-full flex flex-col gap-1.5 overflow-hidden">
|
||||
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">{name}</p>
|
||||
<p className="h-4 text-xs">
|
||||
{mc_loader}
|
||||
{' '}
|
||||
{mc_version}
|
||||
{/* {' '}
|
||||
<>
|
||||
<Link
|
||||
search={{
|
||||
id,
|
||||
}} to="/app/cluster"
|
||||
>
|
||||
<div
|
||||
className="group relative h-[152px] flex flex-col rounded-xl border border-component-border/5 bg-component-bg active:bg-component-bg-pressed hover:bg-component-bg-hover"
|
||||
>
|
||||
<div className="relative flex-1 overflow-hidden rounded-t-xl">
|
||||
<div
|
||||
className="absolute h-full w-full transition-transform group-hover:!scale-110"
|
||||
>
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={DefaultInstancePhoto}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 flex flex-row items-center justify-between gap-x-3 p-3">
|
||||
<div className="h-full flex flex-col gap-1.5 overflow-hidden">
|
||||
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">{name}</p>
|
||||
<p className="h-4 text-xs">
|
||||
{mc_loader}
|
||||
{' '}
|
||||
{mc_version}
|
||||
{/* {' '}
|
||||
{props.packages.mods && `• ${props.mods} mods`} */}
|
||||
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="h-4 text-xs">
|
||||
{mc_loader}
|
||||
{' '}
|
||||
{mc_version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ function RouteComponent() {
|
|||
const { data: usersData, isLoading: usersLoading } = useCommand('getUsers', bindings.core.getUsers);
|
||||
const { data: defaultUserData, isLoading: defaultUserLoading } = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false));
|
||||
|
||||
const addAccount = useCommand('openMsaLogin', bindings.core.openMsaLogin, {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
if (usersLoading || defaultUserLoading)
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
|
|
@ -47,7 +52,7 @@ function RouteComponent() {
|
|||
</ScrollableContainer>
|
||||
|
||||
<div className="mt-auto pt-2 flex flex-row items-end justify-end">
|
||||
<Button color="primary">
|
||||
<Button color="primary" onClick={() => addAccount.refetch()}>
|
||||
<UserPlus02Icon />
|
||||
{' '}
|
||||
Add Account
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import SettingsRow from '@/components/SettingsRow';
|
||||
import useSettings from '@/hooks/useSettings';
|
||||
import { bindings } from '@/main';
|
||||
import { THEMES } from '@/utils/theming';
|
||||
import { Switch } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
|
@ -11,6 +13,15 @@ export const Route = createFileRoute('/app/settings/appearance')({
|
|||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { settings, createSetting } = useSettings();
|
||||
|
||||
const [windowFrame, setFrameStyle] = createSetting('native_window_frame', settings?.native_window_frame);
|
||||
|
||||
const handleWindowFrameChange = async (value: boolean) => {
|
||||
await bindings.onelauncher.set_window_style(value);
|
||||
setFrameStyle(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<ScrollableContainer>
|
||||
|
|
@ -56,7 +67,7 @@ function RouteComponent() {
|
|||
icon={<Monitor01Icon />}
|
||||
title="Custom Window Frame"
|
||||
>
|
||||
<Switch />
|
||||
<Switch defaultSelected={windowFrame} onChange={handleWindowFrameChange} />
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import DiscordIcon from '@/assets/logos/discord.svg';
|
||||
import SettingsRow from '@/components/SettingsRow';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import useSettings from '@/hooks/useSettings';
|
||||
import { Button, Switch } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { FolderIcon, LinkExternal01Icon, XIcon } from '@untitled-theme/icons-react';
|
||||
import { dataDir, join } from '@tauri-apps/api/path';
|
||||
import { openPath } from '@tauri-apps/plugin-opener';
|
||||
import { FolderIcon, LinkExternal01Icon } from '@untitled-theme/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Sidebar from './route';
|
||||
|
||||
export const Route = createFileRoute('/app/settings/')({
|
||||
|
|
@ -12,22 +14,38 @@ export const Route = createFileRoute('/app/settings/')({
|
|||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const _result = useCommand('getGlobalProfile', bindings.core.getGlobalProfile);
|
||||
const { settings, createSetting } = useSettings();
|
||||
const [launcherDir, setLauncherDir] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLauncherDir(await join(await dataDir(), 'OneLauncher'));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const openLauncherDir = async () => {
|
||||
openPath(launcherDir);
|
||||
};
|
||||
|
||||
const [discordRpc, setDiscordRpc] = createSetting('discord_enabled', settings?.discord_enabled);
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<div className="h-full">
|
||||
<h1>General Settings</h1>
|
||||
<h1>General</h1>
|
||||
|
||||
<SettingsRow
|
||||
description="Enable Discord Rich Presence."
|
||||
icon={<img className="w-6 invert-100" src={DiscordIcon} />}
|
||||
title="Discord RPC"
|
||||
>
|
||||
<Switch />
|
||||
<Switch
|
||||
defaultSelected={discordRpc}
|
||||
onChange={val => setDiscordRpc(val)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
{/* <SettingsRow
|
||||
description="Hide the confirmation dialog when closing the launcher."
|
||||
icon={<XIcon />}
|
||||
title="Hide Close Dialog"
|
||||
|
|
@ -35,7 +53,7 @@ function RouteComponent() {
|
|||
<Switch />
|
||||
</SettingsRow>
|
||||
|
||||
{/* <SettingsRow
|
||||
<SettingsRow
|
||||
description="Sends errors and crash logs using Sentry to help developers fix issues. (// TODO)"
|
||||
icon={<AlertSquareIcon />}
|
||||
title="Error Analytics"
|
||||
|
|
@ -48,11 +66,11 @@ function RouteComponent() {
|
|||
|
||||
<SettingsRow.Header>Folders and Files</SettingsRow.Header>
|
||||
<SettingsRow
|
||||
description="Unknown for now"
|
||||
description={launcherDir}
|
||||
icon={<FolderIcon />}
|
||||
title="Launcher Folder"
|
||||
>
|
||||
<Button size="normal">
|
||||
<Button onClick={openLauncherDir} size="normal">
|
||||
<LinkExternal01Icon />
|
||||
{' '}
|
||||
Open
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { SettingProfileModel } from '@/bindings.gen';
|
||||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import SettingsRow from '@/components/SettingsRow';
|
||||
import usePopState from '@/hooks/usePopState';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Switch, TextField } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { Database01Icon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, XIcon } from '@untitled-theme/icons-react';
|
||||
|
|
@ -10,24 +14,41 @@ export const Route = createFileRoute('/app/settings/minecraft')({
|
|||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const result = useCommand('readSettings', bindings.core.readSettings);
|
||||
|
||||
const save = useCommand('writeSettings', () => bindings.core.writeSettings(result.data!), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
usePopState(() => {
|
||||
save.refetch();
|
||||
});
|
||||
|
||||
return (
|
||||
<Sidebar.Page>
|
||||
<ScrollableContainer>
|
||||
<div className="h-full">
|
||||
<h1>Minecraft Settings</h1>
|
||||
|
||||
<GameSettings />
|
||||
<GameSettings settings={result.data!.global_game_settings} />
|
||||
|
||||
{/* <LauncherSettings /> */}
|
||||
|
||||
<ProcessSettings />
|
||||
<ProcessSettings settings={result.data!.global_game_settings} />
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
</Sidebar.Page>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameSettings() {
|
||||
interface GameSettingProps {
|
||||
settings: SettingProfileModel;
|
||||
}
|
||||
|
||||
export function GameSettings(props: GameSettingProps) {
|
||||
const { settings } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsRow.Header>Game</SettingsRow.Header>
|
||||
|
|
@ -37,7 +58,12 @@ export function GameSettings() {
|
|||
icon={<Maximize01Icon />}
|
||||
title="Force Fullscreen"
|
||||
>
|
||||
<Switch />
|
||||
<Switch
|
||||
defaultSelected={settings.force_fullscreen!}
|
||||
onChange={(val) => {
|
||||
settings.force_fullscreen = val;
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
|
|
@ -48,11 +74,19 @@ export function GameSettings() {
|
|||
<div className="grid grid-cols-[70px_16px_70px] gap-2 grid-justify-center grid-items-center">
|
||||
<TextField
|
||||
className="text-center"
|
||||
defaultValue={settings.res?.width.toString()}
|
||||
onChange={(e) => {
|
||||
settings.res!.width = Number(e.target.value);
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
<XIcon className="size-4 self-center" />
|
||||
<TextField
|
||||
className="text-center"
|
||||
defaultValue={settings.res?.height.toString()}
|
||||
onChange={(e) => {
|
||||
settings.res!.height = Number(e.target.value);
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -65,21 +99,12 @@ export function GameSettings() {
|
|||
>
|
||||
<div className="flex items-center gap-x-4 flex-justify-center">
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<span>Min:</span>
|
||||
<TextField
|
||||
className="text-center"
|
||||
max={2024}
|
||||
min={1}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<span>Max:</span>
|
||||
<TextField
|
||||
className="text-center"
|
||||
max={2024}
|
||||
min={1}
|
||||
defaultValue={settings.mem_max?.toString()}
|
||||
onChange={(e) => {
|
||||
settings.mem_max = Number(e.target.value);
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -104,7 +129,13 @@ export function GameSettings() {
|
|||
// );
|
||||
// }
|
||||
|
||||
export function ProcessSettings() {
|
||||
interface ProcessSettingsProps {
|
||||
settings: SettingProfileModel;
|
||||
}
|
||||
|
||||
export function ProcessSettings(props: ProcessSettingsProps) {
|
||||
const { settings } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsRow.Header>Process</SettingsRow.Header>
|
||||
|
|
@ -115,6 +146,10 @@ export function ProcessSettings() {
|
|||
title="Pre-Launch Command"
|
||||
>
|
||||
<TextField
|
||||
defaultValue={settings.hook_pre || ''}
|
||||
onChange={(e) => {
|
||||
settings.hook_pre = e.target.value || null;
|
||||
}}
|
||||
placeholder="echo 'Game started'"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
|
@ -125,6 +160,10 @@ export function ProcessSettings() {
|
|||
title="Wrapper Command"
|
||||
>
|
||||
<TextField
|
||||
defaultValue={settings.hook_wrapper || ''}
|
||||
onChange={(e) => {
|
||||
settings.hook_wrapper = e.target.value || null;
|
||||
}}
|
||||
placeholder="gamescope"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
|
@ -135,6 +174,10 @@ export function ProcessSettings() {
|
|||
title="Post-Exit Command"
|
||||
>
|
||||
<TextField
|
||||
defaultValue={settings.hook_post || ''}
|
||||
onChange={(e) => {
|
||||
settings.hook_post = e.target.value || null;
|
||||
}}
|
||||
placeholder="echo 'Game exited'"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
|
|
|||
|
|
@ -69,8 +69,19 @@ function Sidebar(props: SidebarProps) {
|
|||
navigate({ to: url });
|
||||
}
|
||||
|
||||
function isActive(link: string, _params: URLSearchParams | undefined) {
|
||||
return location === `${base}${link}` || `${location}/` === `${base}${link}`;
|
||||
function isActive(link: string, params: URLSearchParams | undefined) {
|
||||
const fullBasePath = `${base}${link}`;
|
||||
const pathnameMatch = location.startsWith(fullBasePath) || `${location}/`.startsWith(fullBasePath);
|
||||
|
||||
if (!params)
|
||||
return pathnameMatch;
|
||||
|
||||
const searchParams = new URLSearchParams(routerState.location.searchStr);
|
||||
for (const [key, value] of params)
|
||||
if (searchParams.get(key) !== value)
|
||||
return false;
|
||||
|
||||
return pathnameMatch;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -78,7 +89,7 @@ function Sidebar(props: SidebarProps) {
|
|||
{Object.keys(links).map((section, i) => (
|
||||
<div className="flex flex-col gap-y-2" key={i}>
|
||||
<div>
|
||||
<h3 className="m-1.5 mt-5 text-xs text-fg-secondary font-medium">{section.toUpperCase()}</h3>
|
||||
<p className="m-1.5 mt-5 text-xs text-fg-secondary font-medium">{section.toUpperCase()}</p>
|
||||
<div className="flex flex-col gap-y-1 fill-fg-primary text-fg-primary font-medium">
|
||||
{links[section].map((link, i) => {
|
||||
// eslint yaps too much tbh
|
||||
|
|
@ -88,7 +99,7 @@ function Sidebar(props: SidebarProps) {
|
|||
return (
|
||||
<a
|
||||
className={
|
||||
`px-3 py-1 rounded-md text-md border border-component-bg hover:bg-component-bg-hover active:bg-component-bg-pressed ${isActive(link[2], link[3]) ? 'bg-component-bg border-border/05' : 'border-transparent'}`
|
||||
`px-3 py-1 rounded-md text-md border hover:bg-component-bg-hover active:bg-component-bg-pressed ${isActive(link[2], link[3]) ? 'bg-component-bg border-component-bg-pressed' : 'border-transparent'}`
|
||||
}
|
||||
key={i}
|
||||
onClick={() => goto(link[2], link[3])}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useCommand } from '@onelauncher/common';
|
|||
import { Button, Show } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useOnboardingContext } from './route';
|
||||
|
||||
export const Route = createFileRoute('/onboarding/login')({
|
||||
component: RouteComponent,
|
||||
|
|
|
|||
|
|
@ -13,3 +13,20 @@
|
|||
--radius-3xl: 1.5rem;
|
||||
--radius-4xl: 2rem;
|
||||
}
|
||||
|
||||
/* i dont know where to put this */
|
||||
.linearBlur::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
-webkit-backdrop-filter: blur(30);
|
||||
backdrop-filter: blur(30);
|
||||
|
||||
-webkit-mask: linear-gradient(270deg, transparent, black 50%);
|
||||
mask: linear-gradient(270deg, transparent, black 50%);
|
||||
|
||||
background: linear-gradient(270deg, transparent, rgba(0, 0, 0, 0.85) 80%);
|
||||
}
|
||||
95
apps/onelauncher/frontend/src/utils/browser.ts
Normal file
95
apps/onelauncher/frontend/src/utils/browser.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
export const BROWSER_VIEWS: Array<string> = ['grid', 'list'] as const;
|
||||
|
||||
export interface CategoryItem {
|
||||
display: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const browserCategories = {
|
||||
byPackageType(packageType: 'mod' | string, provider: string): Array<CategoryItem> {
|
||||
switch (packageType) {
|
||||
case 'mod':
|
||||
return this.mod(provider);
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
mod(provider: string = 'Modrinth'): Array<CategoryItem> {
|
||||
return Object.values(modMapping[provider]);
|
||||
},
|
||||
};
|
||||
|
||||
const modMapping: Record<string, Array<CategoryItem>> = {
|
||||
Curseforge: [
|
||||
{ display: 'Adventure', id: '422' },
|
||||
{ display: 'API and Library', id: '421' },
|
||||
{ display: 'Armor and Tools', id: '434' },
|
||||
{ display: 'Biomes', id: '407' },
|
||||
{ display: 'Bug Fixes', id: '6821' },
|
||||
{ display: 'Cosmetic', id: '424' },
|
||||
{ display: 'Dimensions', id: '410' },
|
||||
{ display: 'Education', id: '5299' },
|
||||
{ display: 'Energy', id: '417' },
|
||||
{ display: 'Farming', id: '416' },
|
||||
{ display: 'Food', id: '436' },
|
||||
{ display: 'Forestry', id: '433' },
|
||||
{ display: 'Magic', id: '419' },
|
||||
{ display: 'Miscellaneous', id: '425' },
|
||||
{ display: 'Mobs', id: '411' },
|
||||
{ display: 'Ores and Resources', id: '408' },
|
||||
{ display: 'Performance', id: '6814' },
|
||||
{ display: 'Player Transport', id: '414' },
|
||||
{ display: 'Redstone', id: '4558' },
|
||||
{ display: 'Storage', id: '420' },
|
||||
{ display: 'Structures', id: '409' },
|
||||
{ display: 'Technology', id: '412' },
|
||||
{ display: 'Utility & QoL', id: '5191' },
|
||||
{ display: 'World Generation', id: '406' },
|
||||
|
||||
// { display: 'Map and Information', id: '423' },
|
||||
// { display: 'Server Utility', id: '435' },
|
||||
// { display: 'Twitch Integration', id: '4671' },
|
||||
// { display: 'Create', id: '6484' },
|
||||
// { display: 'Buildcraft', id: '432' },
|
||||
// { display: 'Industrial Craft', id: '429' },
|
||||
// { display: 'KubeJS', id: '5314' },
|
||||
// { display: 'Genetics', id: '418' },
|
||||
// { display: 'Blood Magic', id: '4485' },
|
||||
// { display: 'Automation', id: '4843' },
|
||||
// { display: 'CraftTweaker', id: '4773' },
|
||||
// { display: 'Addons', id: '426' },
|
||||
// { display: 'Energy, Fluid, and Item Transport', id: '415' },
|
||||
// { display: 'Thermal Expansion', id: '427' },
|
||||
// { display: 'Thaumcraft', id: '430' },
|
||||
// { display: 'Processing', id: '413' },
|
||||
// { display: 'Tinker\'s Construct', id: '428' },
|
||||
// { display: 'Skyblock', id: '6145' },
|
||||
// { display: 'Galacticraft', id: '5232' },
|
||||
// { display: 'Applied Energistics 2', id: '4545' },
|
||||
// { display: 'Integrated Dynamics', id: '6954' },
|
||||
// { display: 'MCreator', id: '4906' },
|
||||
],
|
||||
Modrinth: [
|
||||
{ display: 'Adventure', id: 'adventure' },
|
||||
{ display: 'Cursed', id: 'cursed' },
|
||||
{ display: 'Decoration', id: 'decoration' },
|
||||
{ display: 'Economy', id: 'economy' },
|
||||
{ display: 'Equipment', id: 'equipment' },
|
||||
{ display: 'Food', id: 'food' },
|
||||
{ display: 'Game Mechanics', id: 'game-mechanics' },
|
||||
{ display: 'Library', id: 'library' },
|
||||
{ display: 'Magic', id: 'magic' },
|
||||
{ display: 'Management', id: 'management' },
|
||||
{ display: 'Minigame', id: 'minigame' },
|
||||
{ display: 'Mobs', id: 'mobs' },
|
||||
{ display: 'Optimization', id: 'optimization' },
|
||||
{ display: 'Social', id: 'social' },
|
||||
{ display: 'Storage', id: 'storage' },
|
||||
{ display: 'Technology', id: 'technology' },
|
||||
{ display: 'Transportation', id: 'transportation' },
|
||||
{ display: 'Utility', id: 'utility' },
|
||||
{ display: 'World Generation', id: 'worldgen' },
|
||||
],
|
||||
SkyClient: [],
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export const LAUNCHER_IMPORT_TYPES: Array<string> = [
|
||||
export const LAUNCHER_IMPORT_TYPES = [
|
||||
'PrismLauncher',
|
||||
'Curseforge',
|
||||
// 'Modrinth',
|
||||
|
|
@ -10,6 +10,17 @@ export const LAUNCHER_IMPORT_TYPES: Array<string> = [
|
|||
// 'Technic'
|
||||
] as const;
|
||||
|
||||
export const LOADERS = [
|
||||
'vanilla',
|
||||
'forge',
|
||||
'neoforge',
|
||||
'quilt',
|
||||
'fabric',
|
||||
'legacyfabric',
|
||||
] as const;
|
||||
export const PROVIDERS = ['Modrinth', 'Curseforge', 'SkyClient'] as const;
|
||||
export const PACKAGE_TYPES = ['mod', 'resourcepack', 'datapack', 'shaderpack'] as const;
|
||||
|
||||
export function pluralize(n: number, word: string, locale: string = 'en'): string {
|
||||
const pluralRules = new Intl.PluralRules(locale);
|
||||
const pluralForm = pluralRules.select(n);
|
||||
|
|
@ -77,3 +88,35 @@ export function formatAsRelative(
|
|||
|
||||
return 'now';
|
||||
}
|
||||
|
||||
function convertSeconds(secondsInput: number): string {
|
||||
let remaining = secondsInput;
|
||||
|
||||
const hours = Math.floor(remaining / 3600);
|
||||
remaining %= 3600;
|
||||
|
||||
const minutes = Math.floor(remaining / 60);
|
||||
remaining %= 60;
|
||||
|
||||
const seconds = remaining;
|
||||
|
||||
const parts: Array<string> = [];
|
||||
|
||||
if (hours)
|
||||
parts.push(`${hours} hours`);
|
||||
if (minutes)
|
||||
parts.push(`${minutes} minutes`);
|
||||
if (seconds)
|
||||
parts.push(`${seconds} seconds`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function formatAsDuration(seconds: number | bigint | Date): string {
|
||||
if (seconds instanceof Date)
|
||||
seconds = seconds.getTime() / 1000;
|
||||
else
|
||||
seconds = Number(seconds);
|
||||
|
||||
return convertSeconds(seconds);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use onelauncher_core::{api::{cluster, packages::{self, download_package, provider::ProviderExt}, proxy::ProxyDynamic}, error::LauncherResult, initialize_core, store::{CoreOptions, Dirs}};
|
||||
use onelauncher_core::{api::{cluster, packages::{self, data::{SearchQuery, Sort}, download_package, provider::ProviderExt}, proxy::ProxyDynamic}, error::LauncherResult, initialize_core, store::{CoreOptions, Dirs}};
|
||||
use onelauncher_entity::{loader::GameLoader, package::Provider};
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -19,6 +19,15 @@ pub async fn main() -> LauncherResult<()> {
|
|||
|
||||
// Fetch our package and its versions
|
||||
let provider = Provider::Modrinth;
|
||||
|
||||
println!("search results: {:#?}", provider.search(&SearchQuery {
|
||||
query: Some("chatting".to_string()),
|
||||
offset: Some(0),
|
||||
limit: Some(10),
|
||||
sort: Some(Sort::Newest),
|
||||
filters: None,
|
||||
}).await?);
|
||||
|
||||
let package = &provider.get("chatting").await?;
|
||||
let versions = provider.get_versions(package.version_ids.as_slice()).await?;
|
||||
|
||||
|
|
|
|||
39
packages/core/src/api/cluster/content.rs
Normal file
39
packages/core/src/api/cluster/content.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use onelauncher_entity::clusters;
|
||||
|
||||
use crate::{error::LauncherResult, store::Dirs, utils::io};
|
||||
|
||||
pub async fn get_screenshots(cluster: &clusters::Model) -> LauncherResult<Vec<String>> {
|
||||
let dir = cluster.folder_name.clone();
|
||||
let path = Dirs::get_clusters_dir().await?.join(dir).join("screenshots");
|
||||
|
||||
if !path.exists() {
|
||||
io::create_dir(&path).await?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut list = vec![];
|
||||
let mut files = io::read_dir(path).await?;
|
||||
while let Ok(Some(entry)) = files.next_entry().await {
|
||||
list.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
pub async fn get_worlds(cluster: &clusters::Model) -> LauncherResult<Vec<String>> {
|
||||
let dir = cluster.folder_name.clone();
|
||||
let path = Dirs::get_clusters_dir().await?.join(dir).join("saves");
|
||||
|
||||
if !path.exists() {
|
||||
io::create_dir(&path).await?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut list = vec![];
|
||||
let mut files = io::read_dir(path).await?;
|
||||
while let Ok(Some(entry)) = files.next_entry().await {
|
||||
list.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ use std::path::Path;
|
|||
use onelauncher_entity::{cluster_groups, clusters, icon::Icon, loader::GameLoader};
|
||||
use sea_orm::{ActiveValue::Set, IntoActiveModel, prelude::*};
|
||||
|
||||
use crate::{error::{DaoError, LauncherResult}, store::State};
|
||||
use crate::{error::{DaoError, LauncherResult}, store::State, utils::io};
|
||||
|
||||
pub type ClusterId = i64;
|
||||
|
||||
|
|
@ -155,3 +155,71 @@ pub async fn get_clusters_grouped() -> LauncherResult<Vec<(cluster_groups::Model
|
|||
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
/// Caches an icon from a file path and updates the cluster to use the cached icon.
|
||||
/// This function copies the image to the icon cache directory and sets the cluster's icon_url
|
||||
/// to reference the cached version.
|
||||
pub async fn set_cluster_icon_by_path(
|
||||
cluster: &mut clusters::Model,
|
||||
icon_path: &str,
|
||||
) -> LauncherResult<()> {
|
||||
let icon_path = Path::new(icon_path);
|
||||
|
||||
if !icon_path.exists() {
|
||||
return Err(anyhow::anyhow!("Icon file does not exist: {}", icon_path.display()).into());
|
||||
}
|
||||
|
||||
let image_bytes = io::read(icon_path).await?;
|
||||
|
||||
let hash = crate::utils::crypto::HashAlgorithm::Sha1.hash(&image_bytes).await?;
|
||||
|
||||
let cache_dir = crate::store::Dirs::get_caches_dir().await?.join("icons");
|
||||
io::create_dir_all(&cache_dir).await?;
|
||||
|
||||
let cached_file_path = cache_dir.join(format!("{}.png", hash));
|
||||
io::write(&cached_file_path, &image_bytes).await?;
|
||||
|
||||
let file_icon = Icon::try_from_path(&cached_file_path)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to create file icon from path: {}", cached_file_path.display()))?;
|
||||
|
||||
update_cluster(cluster, |mut active_model: clusters::ActiveModel| async move {
|
||||
active_model.icon_url = Set(Some(file_icon));
|
||||
Ok(active_model)
|
||||
}).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Caches an icon from a file path and updates the cluster by ID to use the cached icon.
|
||||
/// This function copies the image to the icon cache directory and sets the cluster's icon_url
|
||||
/// to reference the cached version.
|
||||
pub async fn set_icon_by_id(
|
||||
cluster_id: ClusterId,
|
||||
icon_path: &str,
|
||||
) -> LauncherResult<clusters::Model> {
|
||||
let icon_path = Path::new(icon_path);
|
||||
|
||||
if !icon_path.exists() {
|
||||
return Err(anyhow::anyhow!("Icon file does not exist: {}", icon_path.display()).into());
|
||||
}
|
||||
|
||||
let image_bytes = io::read(icon_path).await?;
|
||||
|
||||
let hash = crate::utils::crypto::HashAlgorithm::Sha1.hash(&image_bytes).await?;
|
||||
|
||||
let cache_dir = crate::store::Dirs::get_caches_dir().await?.join("icons");
|
||||
io::create_dir_all(&cache_dir).await?;
|
||||
|
||||
let cached_file_path = cache_dir.join(format!("{}.png", hash));
|
||||
io::write(&cached_file_path, &image_bytes).await?;
|
||||
|
||||
let file_icon = Icon::try_from_path(&cached_file_path)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to create file icon from path: {}", cached_file_path.display()))?;
|
||||
|
||||
let updated_cluster = update_cluster_by_id(cluster_id, |mut active_model: clusters::ActiveModel| async move {
|
||||
active_model.icon_url = Set(Some(file_icon));
|
||||
Ok(active_model)
|
||||
}).await?;
|
||||
|
||||
Ok(updated_cluster)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use crate::store::{Dirs, State};
|
|||
use crate::utils::io::{self, IOError};
|
||||
|
||||
pub mod dao;
|
||||
pub mod content;
|
||||
|
||||
mod sync;
|
||||
pub use sync::*;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{self, PathBuf};
|
||||
|
||||
use async_zip::base::read::mem::ZipFileReader;
|
||||
use interpulse::api::minecraft::VersionInfo;
|
||||
use onelauncher_entity::{java_versions, setting_profiles};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::ingress::{init_ingress, init_ingress_opt, send_ingress};
|
||||
use crate::error::LauncherResult;
|
||||
use crate::store::ingress::IngressType;
|
||||
use crate::utils::io;
|
||||
use crate::store::ingress::{IngressType, SubIngress};
|
||||
use crate::store::{Dirs, State};
|
||||
use crate::utils::http::{fetch_advanced, fetch_json};
|
||||
use crate::utils::io::{self, IOError};
|
||||
|
||||
pub mod dao;
|
||||
|
||||
|
|
@ -16,9 +19,18 @@ pub mod dao;
|
|||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JavaError {
|
||||
#[error("failed to parse version '{0}'")]
|
||||
ParseVersion(String, #[source] #[skip] std::num::ParseIntError),
|
||||
ParseVersion(
|
||||
String,
|
||||
#[source]
|
||||
#[skip]
|
||||
std::num::ParseIntError,
|
||||
),
|
||||
#[error("failed to execute java command")]
|
||||
Execute(#[from] #[skip] std::io::Error),
|
||||
Execute(
|
||||
#[from]
|
||||
#[skip]
|
||||
std::io::Error,
|
||||
),
|
||||
#[error("no java installations found")]
|
||||
MissingJava,
|
||||
}
|
||||
|
|
@ -52,6 +64,14 @@ pub struct JavaInfo {
|
|||
pub java_vendor: String,
|
||||
}
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct JavaPackage {
|
||||
pub download_url: String,
|
||||
pub name: PathBuf,
|
||||
pub java_version: Vec<u32>,
|
||||
}
|
||||
|
||||
const JAVA_INFO_CLASS: &[u8] = include_bytes!("../../../assets/java/JavaInfo.class");
|
||||
|
||||
/// Accepts a path to a java executable and returns the [`JavaInfo`]
|
||||
|
|
@ -124,9 +144,10 @@ pub async fn get_recommended_java(
|
|||
if let Some(profile) = profile
|
||||
&& !profile.is_global()
|
||||
&& let Some(java_id) = profile.java_id
|
||||
&& let Ok(Some(java)) = dao::get_java_by_id(java_id).await {
|
||||
return Ok(Some(java));
|
||||
}
|
||||
&& let Ok(Some(java)) = dao::get_java_by_id(java_id).await
|
||||
{
|
||||
return Ok(Some(java));
|
||||
}
|
||||
|
||||
// Check if the version info has a suggested version
|
||||
let Some(supported_ver) = &info.java_version else {
|
||||
|
|
@ -136,15 +157,8 @@ pub async fn get_recommended_java(
|
|||
dao::get_latest_java_by_major(supported_ver.major_version).await
|
||||
}
|
||||
|
||||
pub async fn prepare_java(
|
||||
major: u32,
|
||||
) -> LauncherResult<java_versions::Model> {
|
||||
let id = init_ingress(
|
||||
IngressType::JavaPrepare,
|
||||
"preparing java",
|
||||
100.0,
|
||||
)
|
||||
.await?;
|
||||
pub async fn prepare_java(major: u32) -> LauncherResult<java_versions::Model> {
|
||||
let id = init_ingress(IngressType::JavaPrepare, "preparing java", 100.0).await?;
|
||||
|
||||
let java = dao::get_latest_java_by_major(major).await?;
|
||||
if let Some(java) = &java {
|
||||
|
|
@ -165,9 +179,17 @@ pub async fn prepare_java(
|
|||
}
|
||||
}
|
||||
|
||||
tracing::warn!("no java installations found on the system, please install java manually or use the launcher to download it");
|
||||
tracing::warn!("no java installations found on the system, attempting to download java {}", major);
|
||||
|
||||
// TODO: java runtime not found - should prob download
|
||||
let java_path = install_java_from_major(major).await?;
|
||||
let java_info = check_java_runtime(&java_path, false).await?;
|
||||
|
||||
dao::insert_java(java_path, java_info).await?;
|
||||
|
||||
if let Some(java) = dao::get_latest_java_by_major(major).await? {
|
||||
send_ingress(&id, 100.0).await?;
|
||||
return Ok(java);
|
||||
}
|
||||
|
||||
Err(JavaError::MissingJava.into())
|
||||
}
|
||||
|
|
@ -206,57 +228,56 @@ pub async fn locate_java() -> LauncherResult<HashMap<PathBuf, JavaInfo>> {
|
|||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn internal_locate_java() -> Vec<PathBuf> {
|
||||
let mut java_homes = Vec::new();
|
||||
|
||||
// epic common paths
|
||||
let common_paths = [
|
||||
r"C:\Program Files\Java",
|
||||
r"C:\Program Files (x86)\Java",
|
||||
r"C:\Program Files\OpenJDK",
|
||||
r"C:\Program Files\Eclipse Adoptium",
|
||||
r"C:\Program Files\Zulu",
|
||||
r"C:\Program Files\Amazon Corretto",
|
||||
];
|
||||
|
||||
for base_path in common_paths {
|
||||
if let Ok(entries) = std::fs::read_dir(base_path) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().map_or(false, |ft| ft.is_dir()) {
|
||||
let java_exe = entry.path().join("bin").join("java.exe");
|
||||
let mut java_homes = Vec::new();
|
||||
|
||||
// epic common paths
|
||||
let common_paths = [
|
||||
r"C:\Program Files\Java",
|
||||
r"C:\Program Files (x86)\Java",
|
||||
r"C:\Program Files\OpenJDK",
|
||||
r"C:\Program Files\Eclipse Adoptium",
|
||||
r"C:\Program Files\Zulu",
|
||||
r"C:\Program Files\Amazon Corretto",
|
||||
];
|
||||
|
||||
for base_path in common_paths {
|
||||
if let Ok(entries) = std::fs::read_dir(base_path) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().map_or(false, |ft| ft.is_dir()) {
|
||||
let java_exe = entry.path().join("bin").join("java.exe");
|
||||
if java_exe.exists() {
|
||||
java_homes.push(java_exe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// env vars
|
||||
if let Ok(java_home) = std::env::var("JAVA_HOME") {
|
||||
let path_buf = PathBuf::from(java_home);
|
||||
if path_buf.join("bin").join("java.exe").exists() {
|
||||
java_homes.push(path_buf);
|
||||
}
|
||||
}
|
||||
java_homes.push(java_exe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// env vars
|
||||
if let Ok(java_home) = std::env::var("JAVA_HOME") {
|
||||
let path_buf = PathBuf::from(java_home);
|
||||
if path_buf.join("bin").join("java.exe").exists() {
|
||||
java_homes.push(path_buf);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
for path_entry in path.split(';') {
|
||||
let java_exe = PathBuf::from(path_entry).join("java.exe");
|
||||
if java_exe.exists() {
|
||||
|
||||
if let Some(bin_dir) = java_exe.parent() {
|
||||
if let Some(java_home) = bin_dir.parent() {
|
||||
let java_home_path = java_home.to_path_buf();
|
||||
if !java_homes.contains(&java_home_path) {
|
||||
java_homes.push(java_home_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java_homes
|
||||
for path_entry in path.split(';') {
|
||||
let java_exe = PathBuf::from(path_entry).join("java.exe");
|
||||
if java_exe.exists() {
|
||||
if let Some(bin_dir) = java_exe.parent() {
|
||||
if let Some(java_home) = bin_dir.parent() {
|
||||
let java_home_path = java_home.to_path_buf();
|
||||
if !java_homes.contains(&java_home_path) {
|
||||
java_homes.push(java_home_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java_homes
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
@ -314,3 +335,101 @@ fn internal_locate_java() -> Vec<PathBuf> {
|
|||
|
||||
found
|
||||
}
|
||||
|
||||
pub async fn get_zulu_packages() -> LauncherResult<Vec<JavaPackage>> {
|
||||
fetch_json::<Vec<JavaPackage>>(
|
||||
reqwest::Method::GET,
|
||||
format!(
|
||||
"https://api.azul.com/metadata/v1/zulu/packages/?os={}&arch={}&archive_type=zip&java_package_type=jre&javafx_bundled=false&latest=true&release_status=ga&availability_types=CA&certifications=tck&page=1&page_size=100",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
).as_str(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn install_java_from_major(version: u32) -> LauncherResult<PathBuf> {
|
||||
let packages = get_zulu_packages().await?;
|
||||
|
||||
let package = packages.into_iter()
|
||||
.find(|p| p.java_version.contains(&version))
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Could not find a java package for version {}",
|
||||
version
|
||||
))?;
|
||||
|
||||
install_java_package(package).await
|
||||
}
|
||||
|
||||
pub async fn install_java_package(package: JavaPackage) -> LauncherResult<PathBuf> {
|
||||
const INGRESS_TOTAL: f64 = 100.0;
|
||||
const INGRESS_TASKS: f64 = 4.0;
|
||||
const INGRESS_STEP: f64 = INGRESS_TOTAL / INGRESS_TASKS;
|
||||
|
||||
let version = package.download_url;
|
||||
|
||||
let ingress_id =
|
||||
init_ingress(IngressType::JavaPrepare, "preparing java", INGRESS_TOTAL).await?;
|
||||
|
||||
let file = fetch_advanced(
|
||||
reqwest::Method::GET,
|
||||
&version,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&SubIngress::new(&ingress_id, INGRESS_STEP)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let java_dir = Dirs::get_java_dir().await?;
|
||||
let archive = ZipFileReader::new(file.to_vec())
|
||||
.await
|
||||
.map_err(IOError::AsyncZipError)?;
|
||||
|
||||
if let Some(entry) = archive.file().entries().first() {
|
||||
if let Some(dir) = entry
|
||||
.filename()
|
||||
.clone()
|
||||
.into_string()
|
||||
.unwrap()
|
||||
.split('/')
|
||||
.next()
|
||||
{
|
||||
let path = java_dir.join(dir);
|
||||
if path.exists() {
|
||||
io::remove_dir_all(&path).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
io::unzip_bytes(archive.data().to_vec(), &java_dir).await?;
|
||||
|
||||
let mut base_path = java_dir.join(
|
||||
package
|
||||
.name
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let java_version = package.java_version.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(".");
|
||||
base_path = base_path
|
||||
.join(format!("zulu-{java_version}.jre"))
|
||||
.join("Contents")
|
||||
.join("Home")
|
||||
.join("bin")
|
||||
.join("java");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
base_path = base_path.join("bin").join(crate::constants::JAVA_BIN);
|
||||
}
|
||||
|
||||
Ok(base_path)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,52 @@ pub enum Sort {
|
|||
Updated
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Sort {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", match self {
|
||||
Self::Relevance => "relevance",
|
||||
Self::Downloads => "downloads",
|
||||
Self::Newest => "newest",
|
||||
Self::Updated => "updated",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub query: Option<String>,
|
||||
pub offset: Option<usize>,
|
||||
pub limit: Option<usize>,
|
||||
pub sort: Option<String>,
|
||||
pub sort: Option<Sort>,
|
||||
pub filters: Option<Filters>,
|
||||
}
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
pub project_id: String,
|
||||
pub project_type: String,
|
||||
pub slug: String,
|
||||
pub author: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub categories: Vec<String>,
|
||||
pub display_categories: Vec<String>,
|
||||
pub versions: Vec<String>,
|
||||
pub downloads: usize,
|
||||
pub icon_url: String,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub date_modified: DateTime<Utc>,
|
||||
pub latest_version: String,
|
||||
pub license: String,
|
||||
pub client_side: PackageSide,
|
||||
pub server_side: PackageSide,
|
||||
/// List of URLs to images
|
||||
pub gallery: Vec<String>,
|
||||
// pub color: i64,
|
||||
}
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use onelauncher_entity::loader::GameLoader;
|
||||
use onelauncher_entity::package::Provider;
|
||||
|
||||
use crate::error::LauncherResult;
|
||||
use crate::{api::packages::data::SearchResult, error::LauncherResult};
|
||||
use crate::utils::pagination::Paginated;
|
||||
|
||||
use super::data::{ManagedPackage, ManagedUser, ManagedVersion, PackageAuthor, SearchQuery};
|
||||
|
|
@ -10,9 +10,9 @@ mod modrinth;
|
|||
|
||||
pub use modrinth::ModrinthProviderImpl;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
#[async_trait::async_trait]
|
||||
pub trait ProviderExt {
|
||||
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<ManagedPackage>>;
|
||||
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<SearchResult>>;
|
||||
async fn get(&self, slug: &str) -> LauncherResult<ManagedPackage>;
|
||||
async fn get_multiple(&self, slugs: &[String]) -> LauncherResult<Vec<ManagedPackage>>;
|
||||
async fn get_versions_by_hashes(
|
||||
|
|
@ -42,9 +42,9 @@ pub trait ProviderExt {
|
|||
async fn get_versions(&self, slugs: &[String]) -> LauncherResult<Vec<ManagedVersion>>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderExt for Provider {
|
||||
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<ManagedPackage>> {
|
||||
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<SearchResult>> {
|
||||
match self {
|
||||
Self::Modrinth => ModrinthProviderImpl.search(query).await,
|
||||
_ => todo!("unimplemented provider"),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
use super::ProviderExt;
|
||||
use crate::api::packages::data::{
|
||||
ManagedPackage, ManagedUser, ManagedVersion, ManagedVersionDependency, ManagedVersionFile,
|
||||
PackageAuthor, PackageDependencyType, PackageDonationUrl, PackageLicense, PackageLinks,
|
||||
PackageReleaseType, PackageSide, PackageStatus, SearchQuery, DEFAULT_LIMIT,
|
||||
ManagedPackage, ManagedUser, ManagedVersion, ManagedVersionDependency, ManagedVersionFile, PackageAuthor, PackageDependencyType, PackageDonationUrl, PackageLicense, PackageLinks, PackageReleaseType, PackageSide, PackageStatus, SearchQuery, SearchResult, DEFAULT_LIMIT
|
||||
};
|
||||
use crate::error::LauncherResult;
|
||||
use crate::utils::http;
|
||||
|
|
@ -37,16 +35,71 @@ impl ModrinthProviderImpl {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderExt for ModrinthProviderImpl {
|
||||
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<ManagedPackage>> {
|
||||
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<SearchResult>> {
|
||||
let mut url = Url::parse(url!("/search"))?;
|
||||
let mut params = url.query_pairs_mut();
|
||||
|
||||
params.append_pair("limit", &query.limit.unwrap_or(DEFAULT_LIMIT).to_string());
|
||||
params.append_pair("offset", &query.offset.unwrap_or(0).to_string());
|
||||
{
|
||||
let mut params = url.query_pairs_mut();
|
||||
|
||||
todo!()
|
||||
params.append_pair("limit", &query.limit.unwrap_or(DEFAULT_LIMIT).to_string());
|
||||
params.append_pair("offset", &query.offset.unwrap_or(0).to_string());
|
||||
params.append_pair("query", query.query.clone().unwrap_or_default().as_ref());
|
||||
|
||||
if let Some(filters) = &query.filters {
|
||||
let mut builder = FacetBuilder::builder();
|
||||
|
||||
if let Some(categories) = &filters.categories {
|
||||
for category in categories {
|
||||
builder.and(Facet("categories".to_string(), FacetOperation::Eq, category.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mc_versions) = &filters.game_versions {
|
||||
for mc_version in mc_versions {
|
||||
builder.and(Facet("versions".to_string(), FacetOperation::Eq, mc_version.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(package_types) = &filters.package_types {
|
||||
for package_type in package_types {
|
||||
builder.and(Facet("project_type".to_string(), FacetOperation::Eq, package_type_to_string(package_type)));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(loaders) = &filters.loaders {
|
||||
for loader in loaders {
|
||||
builder.and(Facet("loaders".to_string(), FacetOperation::Eq, loader.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
params.append_pair("facets", &builder.build());
|
||||
}
|
||||
|
||||
if let Some(sort) = &query.sort {
|
||||
params.append_pair("index", &sort.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let url = url.as_str();
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchResults {
|
||||
pub hits: Vec<SearchResult>,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub total_hits: usize,
|
||||
}
|
||||
|
||||
let response = http::fetch_json::<SearchResults>(Method::GET, url, None, None).await?;
|
||||
|
||||
Ok(Paginated {
|
||||
offset: response.offset,
|
||||
limit: response.limit,
|
||||
total: response.total_hits,
|
||||
items: response.hits,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get(&self, slug: &str) -> LauncherResult<ManagedPackage> {
|
||||
|
|
@ -250,6 +303,17 @@ impl ProviderExt for ModrinthProviderImpl {
|
|||
}
|
||||
}
|
||||
|
||||
fn package_type_to_string(package_type: &PackageType) -> String {
|
||||
match package_type {
|
||||
PackageType::Mod => "mod",
|
||||
PackageType::ResourcePack => "resourcepack",
|
||||
PackageType::Shader => "shader",
|
||||
PackageType::DataPack => "datapack",
|
||||
PackageType::ModPack => "modpack",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
struct ModrinthPackage {
|
||||
|
|
@ -471,3 +535,77 @@ impl From<ModrinthUser> for ManagedUser {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
enum FacetOperation {
|
||||
NotEq,
|
||||
LargeEq,
|
||||
Large,
|
||||
SmallEq,
|
||||
Small,
|
||||
Eq,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FacetOperation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::NotEq => "!=",
|
||||
Self::LargeEq => ">=",
|
||||
Self::Large => ">",
|
||||
Self::SmallEq => "<=",
|
||||
Self::Small => "<",
|
||||
Self::Eq => "=",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Facet(pub String, pub FacetOperation, pub String);
|
||||
|
||||
impl std::fmt::Display for Facet {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(format!("{}{}{}", self.0, self.1, self.2).as_str())
|
||||
}
|
||||
}
|
||||
|
||||
struct FacetBuilder {
|
||||
pub facets: Vec<Vec<Facet>>,
|
||||
}
|
||||
|
||||
impl FacetBuilder {
|
||||
pub const fn builder() -> Self {
|
||||
Self { facets: vec![] }
|
||||
}
|
||||
|
||||
pub fn and(&mut self, facet: Facet) -> &Self {
|
||||
self.facets.push(vec![facet]);
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn or(&mut self, facet: Facet) -> &Self {
|
||||
let mut last_facet = self.facets.pop().unwrap_or_default();
|
||||
last_facet.push(facet);
|
||||
self.facets.push(last_facet);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(&self) -> String {
|
||||
let mut builder: Vec<String> = vec![];
|
||||
|
||||
for facet in &self.facets {
|
||||
let mut stringified = String::new();
|
||||
stringified.push('[');
|
||||
for (i, f) in facet.iter().enumerate() {
|
||||
if i != 0 {
|
||||
stringified.push(',');
|
||||
}
|
||||
stringified.push_str(format!("\"{f}\"").as_str());
|
||||
}
|
||||
stringified.push(']');
|
||||
|
||||
builder.push(stringified);
|
||||
}
|
||||
|
||||
builder.join(",")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
use interpulse::api::minecraft::Version;
|
||||
use onelauncher_entity::{icon::Icon, loader::GameLoader};
|
||||
use onelauncher_entity::{icon::Icon, loader::GameLoader, package::Provider, packages, resolution::Resolution};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use crate::{api::packages::{data::{SearchResult, ManagedPackage, ManagedUser, ManagedVersion, SearchQuery}, provider::ProviderExt}, store::{Settings, State}, utils::pagination::Paginated};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
use crate::{api::{self, cluster::dao::ClusterId}, error::LauncherResult, store::{credentials::MinecraftCredentials, Core}};
|
||||
use crate::{api::{self, cluster::dao::ClusterId}, error::{LauncherError, LauncherResult}, store::{credentials::MinecraftCredentials, Core}};
|
||||
|
||||
use onelauncher_entity::prelude::model::*;
|
||||
|
||||
|
|
@ -25,8 +27,14 @@ pub trait TauriLauncherApi {
|
|||
#[taurpc(alias = "launchCluster")]
|
||||
async fn launch_cluster(id: ClusterId, uuid: Option<uuid::Uuid>) -> LauncherResult<()>;
|
||||
|
||||
// #[taurpc(alias = "updateClusterById")]
|
||||
// async fn update_cluster_by_id(id: ClusterId, request: Cluster) -> LauncherResult<()>;
|
||||
#[taurpc(alias = "updateClusterById")]
|
||||
async fn update_cluster_by_id(id: ClusterId, request: ClusterUpdate) -> LauncherResult<()>;
|
||||
|
||||
#[taurpc(alias = "getScreenshots")]
|
||||
async fn get_screenshots(id: ClusterId) -> LauncherResult<Vec<String>>;
|
||||
|
||||
#[taurpc(alias = "getWorlds")]
|
||||
async fn get_worlds(id: ClusterId) -> LauncherResult<Vec<String>>;
|
||||
|
||||
|
||||
// Setting Profiles
|
||||
|
|
@ -36,6 +44,9 @@ pub trait TauriLauncherApi {
|
|||
#[taurpc(alias = "getGlobalProfile")]
|
||||
async fn get_global_profile() -> SettingsProfile;
|
||||
|
||||
#[taurpc(alias = "updateClusterProfile")]
|
||||
async fn update_cluster_profile(name: String, profile: ProfileUpdate) -> LauncherResult<SettingsProfile>;
|
||||
|
||||
// Game Metadata
|
||||
#[taurpc(alias = "getGameVersions")]
|
||||
async fn get_game_versions() -> LauncherResult<Vec<Version>>;
|
||||
|
|
@ -62,6 +73,32 @@ pub trait TauriLauncherApi {
|
|||
|
||||
#[taurpc(alias = "openMsaLogin")]
|
||||
async fn open_msa_login<R: Runtime>(app_handle: AppHandle<R>) -> LauncherResult<Option<MinecraftCredentials>>;
|
||||
|
||||
// Settings
|
||||
#[taurpc(alias = "readSettings")]
|
||||
async fn read_settings() -> LauncherResult<Settings>;
|
||||
|
||||
#[taurpc(alias = "writeSettings")]
|
||||
async fn write_settings(setting: Settings) -> LauncherResult<()>;
|
||||
|
||||
// Packages
|
||||
#[taurpc(alias = "searchPackages")]
|
||||
async fn search_packages(provider: Provider, query: SearchQuery) -> LauncherResult<Paginated<SearchResult>>;
|
||||
|
||||
#[taurpc(alias = "getPackage")]
|
||||
async fn get_package(provider: Provider, slug: String) -> LauncherResult<ManagedPackage>;
|
||||
|
||||
#[taurpc(alias = "getMultiplePackages")]
|
||||
async fn get_multiple_packages(provider: Provider, slugs: Vec<String>) -> LauncherResult<Vec<ManagedPackage>>;
|
||||
|
||||
#[taurpc(alias = "getPackageVersions")]
|
||||
async fn get_package_versions(provider: Provider, slug: String, mc_versions: Option<Vec<String>>, loaders: Option<Vec<GameLoader>>, offset: usize, limit: usize) -> LauncherResult<Paginated<ManagedVersion>>;
|
||||
|
||||
#[taurpc(alias = "getPackageUser")]
|
||||
async fn get_package_user(provider: Provider, slug: String) -> LauncherResult<ManagedUser>;
|
||||
|
||||
#[taurpc(alias = "downloadPackage")]
|
||||
async fn download_package(provider: Provider, package_id: String, version_id: String, cluster_id: ClusterId, skip_compatibility: Option<bool>) -> LauncherResult<packages::Model>;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -74,6 +111,23 @@ pub struct CreateCluster {
|
|||
icon: Option<Icon>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)]
|
||||
pub struct ClusterUpdate {
|
||||
name: Option<String>,
|
||||
icon_url: Option<Icon>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)]
|
||||
pub struct ProfileUpdate {
|
||||
pub res: Option<Resolution>,
|
||||
pub force_fullscreen: Option<bool>,
|
||||
pub mem_max: Option<u32>,
|
||||
pub launch_args: Option<String>,
|
||||
pub launch_env: Option<String>,
|
||||
pub hook_pre: Option<String>,
|
||||
pub hook_wrapper: Option<String>,
|
||||
pub hook_post: Option<String>,
|
||||
}
|
||||
|
||||
#[taurpc::ipc_type]
|
||||
pub struct TauriLauncherApiImpl;
|
||||
|
|
@ -97,6 +151,22 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
|||
async fn create_cluster(self, options: CreateCluster) -> LauncherResult<Cluster> {
|
||||
let cluster = api::cluster::create_cluster(&options.name, &options.mc_version, options.mc_loader, options.mc_loader_version.as_deref(), options.icon).await?;
|
||||
|
||||
api::setting_profiles::create_profile(&options.name, |mut active_model: SettingsProfilePartial| async move {
|
||||
active_model.force_fullscreen = Set(Some(false));
|
||||
|
||||
active_model.mem_max = Set(Some(2048));
|
||||
|
||||
Ok(active_model)
|
||||
})
|
||||
.await?;
|
||||
|
||||
api::cluster::dao::update_cluster_by_id(cluster.id, |mut active_model: ClusterPartial| async move {
|
||||
active_model.setting_profile_name = Set(Some(options.name.clone()));
|
||||
|
||||
Ok(active_model)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(cluster)
|
||||
}
|
||||
|
||||
|
|
@ -119,22 +189,38 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// async fn update_cluster_by_id(self, id: ClusterId, request: clusters::ActiveModel) -> LauncherResult<()> {
|
||||
// // let updated_cluster = api::cluster::dao::update_cluster_by_id(id, |mut active_model| async move {
|
||||
// // if let Some(name) = request.name {
|
||||
// // active_model.name = Set(name);
|
||||
// // }
|
||||
async fn update_cluster_by_id(self, id: ClusterId, request: ClusterUpdate) -> LauncherResult<()> {
|
||||
api::cluster::dao::update_cluster_by_id(id, |mut active_model: ClusterPartial| async move {
|
||||
if let Some(name) = request.name {
|
||||
active_model.name = Set(name)
|
||||
}
|
||||
|
||||
// // active_model.updated_at = Set(chrono::Utc::now().naive_utc());
|
||||
// ok i know this is so wrong but im not a rust guy
|
||||
if let Some(icon_url) = request.icon_url {
|
||||
api::cluster::dao::set_icon_by_id(id, &icon_url).await?;
|
||||
}
|
||||
|
||||
// // Ok(active_model)
|
||||
// // })
|
||||
// // .await
|
||||
// // .map_err(|e| e.to_string())?;
|
||||
Ok(active_model)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| LauncherError::from(e))?;
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_screenshots(self, id: ClusterId) -> LauncherResult<Vec<String>> {
|
||||
let cluster = api::cluster::dao::get_cluster_by_id(id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", id))?;
|
||||
|
||||
api::cluster::content::get_screenshots(&cluster).await
|
||||
}
|
||||
|
||||
async fn get_worlds(self, id: ClusterId) -> LauncherResult<Vec<String>> {
|
||||
let cluster = api::cluster::dao::get_cluster_by_id(id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", id))?;
|
||||
|
||||
api::cluster::content::get_worlds(&cluster).await
|
||||
}
|
||||
|
||||
// Setting Profiles
|
||||
async fn get_global_profile(self) -> SettingsProfile {
|
||||
|
|
@ -145,6 +231,47 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
|||
api::setting_profiles::dao::get_profile_or_default(name.as_ref()).await
|
||||
}
|
||||
|
||||
// please kill this with fire
|
||||
async fn update_cluster_profile(self, name: String, profile: ProfileUpdate) -> LauncherResult<SettingsProfile> {
|
||||
let profile = api::setting_profiles::dao::update_profile_by_name(&name, |mut active_model: SettingsProfilePartial| async move {
|
||||
if let Some(res) = profile.res {
|
||||
active_model.res = Set(Some(res));
|
||||
}
|
||||
|
||||
if let Some(force_fullscreen) = profile.force_fullscreen {
|
||||
active_model.force_fullscreen = Set(Some(force_fullscreen));
|
||||
}
|
||||
|
||||
if let Some(mem_max) = profile.mem_max {
|
||||
active_model.mem_max = Set(Some(mem_max));
|
||||
}
|
||||
|
||||
if let Some(launch_args) = profile.launch_args {
|
||||
active_model.launch_args = Set(Some(launch_args));
|
||||
}
|
||||
|
||||
if let Some(launch_env) = profile.launch_env {
|
||||
active_model.launch_env = Set(Some(launch_env));
|
||||
}
|
||||
|
||||
if let Some(hook_pre) = profile.hook_pre {
|
||||
active_model.hook_pre = Set(Some(hook_pre));
|
||||
}
|
||||
|
||||
if let Some(hook_wrapper) = profile.hook_wrapper {
|
||||
active_model.hook_wrapper = Set(Some(hook_wrapper));
|
||||
}
|
||||
|
||||
if let Some(hook_post) = profile.hook_post {
|
||||
active_model.hook_post = Set(Some(hook_post));
|
||||
}
|
||||
|
||||
Ok(active_model)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
// Game Metadata
|
||||
async fn get_loaders_for_version(self, mc_version: String) -> LauncherResult<Vec<GameLoader>> {
|
||||
|
|
@ -242,4 +369,60 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
|||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn read_settings(self) -> LauncherResult<Settings> {
|
||||
let state = State::get().await?;
|
||||
let settings = state.settings.read().await;
|
||||
|
||||
Ok(settings.clone())
|
||||
}
|
||||
|
||||
async fn write_settings(self, setting: Settings) -> LauncherResult<()> {
|
||||
let state = State::get().await?;
|
||||
let mut settings = state.settings.write().await;
|
||||
|
||||
*settings = setting;
|
||||
|
||||
settings.save().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Packages
|
||||
async fn search_packages(self, provider: Provider, query: SearchQuery) -> LauncherResult<Paginated<SearchResult>> {
|
||||
Ok(provider.search(&query).await?)
|
||||
}
|
||||
|
||||
async fn get_package(self, provider: Provider, slug: String) -> LauncherResult<ManagedPackage> {
|
||||
Ok(provider.get(&slug).await?)
|
||||
}
|
||||
|
||||
async fn get_multiple_packages(self, provider: Provider, slugs: Vec<String>) -> LauncherResult<Vec<ManagedPackage>> {
|
||||
Ok(provider.get_multiple(&slugs).await?)
|
||||
}
|
||||
|
||||
async fn get_package_versions(self, provider: Provider, slug: String, mc_versions: Option<Vec<String>>, loaders: Option<Vec<GameLoader>>, offset: usize, limit: usize) -> LauncherResult<Paginated<ManagedVersion>> {
|
||||
Ok(provider.get_versions_paginated(&slug, mc_versions, loaders, offset, limit).await?)
|
||||
}
|
||||
|
||||
async fn get_package_user(self, provider: Provider, slug: String) -> LauncherResult<ManagedUser> {
|
||||
Ok(provider.get_user(&slug).await?)
|
||||
}
|
||||
|
||||
async fn download_package(self, provider: Provider, package_id: String, version_id: String, cluster_id: ClusterId, skip_compatibility: Option<bool>) -> LauncherResult<packages::Model> {
|
||||
let cluster = api::cluster::dao::get_cluster_by_id(cluster_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", cluster_id))?;
|
||||
|
||||
let package = provider.get(&package_id).await?;
|
||||
|
||||
let versions = provider.get_versions(&[version_id]).await?;
|
||||
let version = versions.into_iter().next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Version not found"))?;
|
||||
|
||||
let model = api::packages::download_package(&package, &version, None).await?;
|
||||
|
||||
api::packages::link_package(&model, &cluster, skip_compatibility).await?;
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,14 +41,15 @@ pub async fn start_logger() {
|
|||
// This will log to a file in the logs directory, and will not show any logs in the console
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub async fn start_logger() {
|
||||
use chrono::Local;
|
||||
use chrono::Local;
|
||||
use std::fs::OpenOptions;
|
||||
use tracing_subscriber::fmt::time::ChronoLocal;
|
||||
|
||||
use crate::io::Dirs;
|
||||
|
||||
use crate::store::Dirs;
|
||||
use crate::store::Core;
|
||||
|
||||
// Initialize and get logs directory path
|
||||
let logs_dir = Dirs::get().await?.launcher_logs_dir();
|
||||
let logs_dir = Dirs::get_launcher_logs_dir().await.unwrap_or_default();
|
||||
|
||||
let log_file_name = format!("launcher_{}.log", Local::now().format("%Y%m%d_%H%M%S"));
|
||||
let log_file_path = logs_dir.join(log_file_name);
|
||||
|
|
@ -65,7 +66,7 @@ pub async fn start_logger() {
|
|||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
eprintln!("Could not start open log file: {e}");
|
||||
return None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -86,7 +87,7 @@ pub async fn start_logger() {
|
|||
.with_ansi(false)
|
||||
.with_timer(ChronoLocal::rfc_3339());
|
||||
|
||||
if let Some(span) = Core::get().span_formatting.clone() {
|
||||
if let Some(span) = Core::get().logger_span_formatting.clone() {
|
||||
fmt_layer = fmt_layer.with_span_events(span);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub struct Settings {
|
|||
pub discord_enabled: bool,
|
||||
pub max_concurrent_requests: usize,
|
||||
pub settings_version: u32,
|
||||
pub native_window_frame: bool
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
|
|
@ -26,6 +27,7 @@ impl Default for Settings {
|
|||
enable_gamemode: false,
|
||||
max_concurrent_requests: 25,
|
||||
settings_version: 1,
|
||||
native_window_frame: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use std::marker::Send;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Paginated<T>
|
||||
where T: Clone + Serialize {
|
||||
where T: Clone + Serialize + Send + Sync {
|
||||
pub total: usize,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub items: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T: Clone + Serialize> Debug for Paginated<T> {
|
||||
impl<T: Clone + Serialize + Send + Sync> Debug for Paginated<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Paginated")
|
||||
.field("total", &self.total)
|
||||
|
|
@ -23,7 +23,7 @@ impl<T: Clone + Serialize> Debug for Paginated<T> {
|
|||
}
|
||||
}
|
||||
|
||||
impl <T: Clone + Serialize> Paginated<T> {
|
||||
impl <T: Clone + Serialize + Send + Sync> Paginated<T> {
|
||||
#[must_use]
|
||||
pub const fn new(total: usize, offset: usize, limit: usize, items: Vec<T>) -> Self {
|
||||
Self { total, offset, limit, items }
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ impl Icon {
|
|||
return None;
|
||||
}
|
||||
|
||||
Some(Self(format!("file://{}", path.display())))
|
||||
Some(Self(format!("{}", path.display())))
|
||||
}
|
||||
|
||||
pub fn try_from_url(url: url::Url) -> Option<Self> {
|
||||
|
|
|
|||
|
|
@ -42,5 +42,9 @@
|
|||
"vite": "^6.1.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-static-copy": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@untitled-theme/icons-react": "^0.14.0",
|
||||
"overlayscrollbars-react": "^0.5.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
import type { VariantProps } from 'tailwind-variants';
|
||||
import type { ClassNameString } from '../types/global';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@untitled-theme/icons-react';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import React from 'react';
|
||||
import {
|
||||
Label as AriaLabel,
|
||||
|
|
@ -36,15 +37,19 @@ const dropdownStyles = tv({
|
|||
triggerCustomIcon: 'w-4 h-4',
|
||||
|
||||
popover: [
|
||||
'mt-1 rounded-lg shadow-md',
|
||||
// i'll fix this if i dont forget
|
||||
'mt-1 w-full rounded-lg shadow-md',
|
||||
'bg-component-bg border border-component-border',
|
||||
'data-[entering]:animate-in data-[entering]:fade-in data-[entering]:zoom-in-95',
|
||||
'data-[exiting]:animate-out data-[exiting]:fade-out data-[exiting]:zoom-out-95',
|
||||
'w-(--trigger-width)',
|
||||
],
|
||||
|
||||
popoverContentWrapper: 'flex flex-col gap-1',
|
||||
|
||||
listBox: 'max-h-46 overflow-auto outline-none flex flex-col gap-0.5',
|
||||
listBox: 'outline-none flex flex-col gap-0.5',
|
||||
|
||||
overlayScrollbars: 'max-h-46 overflow-auto',
|
||||
|
||||
listToolRowWrapper: 'p-1 mt-1',
|
||||
},
|
||||
|
|
@ -135,9 +140,11 @@ export function Dropdown<TValue extends object>(props: DropdownProps<TValue>) {
|
|||
|
||||
<AriaPopover className={twMerge(styles.popover(), popoverClassName)}>
|
||||
<div className={styles.popoverContentWrapper()}>
|
||||
<AriaListBox className={styles.listBox()}>
|
||||
{children}
|
||||
</AriaListBox>
|
||||
<OverlayScrollbarsComponent className={styles.overlayScrollbars()}>
|
||||
<AriaListBox className={styles.listBox()}>
|
||||
{children}
|
||||
</AriaListBox>
|
||||
</OverlayScrollbarsComponent>
|
||||
{listToolRow && (
|
||||
<div className={styles.listToolRowWrapper()}>
|
||||
{listToolRow}
|
||||
|
|
|
|||
22
packages/web_common/src/components/Popup.tsx
Normal file
22
packages/web_common/src/components/Popup.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { PopoverProps } from 'react-aria-components';
|
||||
import { Dialog, DialogTrigger, Popover } from 'react-aria-components';
|
||||
|
||||
interface MyPopoverProps extends Omit<PopoverProps, 'children'> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Popup({ children, ...props }: MyPopoverProps) {
|
||||
return (
|
||||
<Popover {...props} className="flex flex-col gap-0.5 rounded-lg border border-component-border-hover">
|
||||
<Dialog className="bg-page-elevated p-2 shadow-md rounded-lg">
|
||||
{children}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function Trigger({ children, ...props }: any) {
|
||||
return <DialogTrigger children={children} {...props} />;
|
||||
}
|
||||
|
||||
Popup.Trigger = Trigger;
|
||||
|
|
@ -3,6 +3,7 @@ export * from './Button';
|
|||
export * from './ContextMenu';
|
||||
export * from './Dropdown';
|
||||
export * from './Menu';
|
||||
export * from './Popup';
|
||||
export * from './SelectList';
|
||||
export * from './Show';
|
||||
export * from './Switch';
|
||||
|
|
|
|||
|
|
@ -109,3 +109,14 @@ body {
|
|||
.os-hide-horizontal-scrollbar > .os-scrollbar-horizontal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 { @apply text-[2rem] font-semibold; line-height: 1.5 !important; }
|
||||
h2 { @apply text-[1.5rem] font-semibold; line-height: 1.25 !important; }
|
||||
h3 { @apply text-[1.25rem] font-medium; }
|
||||
h4 { @apply text-[1rem] font-medium; }
|
||||
h5 { @apply text-[0.875rem] text-fg-secondary font-bold uppercase; }
|
||||
h6 { @apply text-[0.75rem] text-fg-secondary font-medium uppercase; }
|
||||
|
||||
code {
|
||||
@apply rounded-md p-2 text-left;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import path from 'node:path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, normalizePath } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ export default defineConfig({
|
|||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: path.resolve(__dirname, 'src/theme.css'),
|
||||
src: normalizePath(path.resolve(__dirname, 'src/theme.css')),
|
||||
dest: './',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
40
pnpm-lock.yaml
generated
40
pnpm-lock.yaml
generated
|
|
@ -249,6 +249,9 @@ importers:
|
|||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
|
|
@ -291,6 +294,9 @@ importers:
|
|||
taurpc:
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
valtio:
|
||||
specifier: ^2.1.5
|
||||
version: 2.1.5(@types/react@19.1.2)(react@19.1.0)
|
||||
devDependencies:
|
||||
'@tanstack/eslint-config':
|
||||
specifier: ^0.1.0
|
||||
|
|
@ -365,6 +371,9 @@ importers:
|
|||
motion:
|
||||
specifier: ^12.10.5
|
||||
version: 12.10.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
overlayscrollbars-react:
|
||||
specifier: ^0.5.6
|
||||
version: 0.5.6(overlayscrollbars@2.11.2)(react@19.1.0)
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.1.0
|
||||
|
|
@ -2814,6 +2823,9 @@ packages:
|
|||
'@tauri-apps/plugin-dialog@2.2.1':
|
||||
resolution: {integrity: sha512-wZmCouo4PgTosh/UoejPw9DPs6RllS5Pp3fuOV2JobCu36mR5AXU2MzU9NZiVaFi/5Zfc8RN0IhcZHnksJ1o8A==}
|
||||
|
||||
'@tauri-apps/plugin-opener@2.3.0':
|
||||
resolution: {integrity: sha512-yAbauwp8BCHIhhA48NN8rEf6OtfZBPCgTOCa10gmtoVCpmic5Bq+1Ba7C+NZOjogedkSiV7hAotjYnnbUVmYrw==}
|
||||
|
||||
'@tauri-apps/plugin-shell@2.2.1':
|
||||
resolution: {integrity: sha512-G1GFYyWe/KlCsymuLiNImUgC8zGY0tI0Y3p8JgBCWduR5IEXlIJS+JuG1qtveitwYXlfJrsExt3enhv5l2/yhA==}
|
||||
|
||||
|
|
@ -4905,6 +4917,9 @@ packages:
|
|||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
proxy-compare@3.0.1:
|
||||
resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -5489,6 +5504,18 @@ packages:
|
|||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
valtio@2.1.5:
|
||||
resolution: {integrity: sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
react: '>=18.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
vite-node@2.1.3:
|
||||
resolution: {integrity: sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
|
@ -8521,6 +8548,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-opener@2.3.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
||||
'@tauri-apps/plugin-shell@2.2.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.5.0
|
||||
|
|
@ -10956,6 +10987,8 @@ snapshots:
|
|||
|
||||
process@0.11.10: {}
|
||||
|
||||
proxy-compare@3.0.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
quansync@0.2.10: {}
|
||||
|
|
@ -11687,6 +11720,13 @@ snapshots:
|
|||
spdx-correct: 3.2.0
|
||||
spdx-expression-parse: 3.0.1
|
||||
|
||||
valtio@2.1.5(@types/react@19.1.2)(react@19.1.0):
|
||||
dependencies:
|
||||
proxy-compare: 3.0.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.2
|
||||
react: 19.1.0
|
||||
|
||||
vite-node@2.1.3(@types/node@22.7.9)(lightningcss@1.30.1)(sass@1.80.3):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue