Spice up onboarding
This commit is contained in:
parent
1c3041679e
commit
445eca2775
18 changed files with 481 additions and 127 deletions
|
|
@ -325,38 +325,10 @@ export type VersionType =
|
||||||
*/
|
*/
|
||||||
"old_beta"
|
"old_beta"
|
||||||
|
|
||||||
const ARGS_MAP = { 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'oneclient':'{"refreshArt":["path"],"copyClusterContent":["source_id","target_id"],"getBundlesFor":["cluster_id"],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"updateBundlePackages":["cluster_id"],"extractBundleOverrides":["bundle_path","cluster_id"],"getClustersGroupedByMajor":[],"getVersions":[],"checkForUpdate":[],"installUpdate":[],"isBundleSyncing":[],"cacheArt":["path"]}', 'core':'{"getClusterById":["id"],"getProfileOrDefault":["name"],"refreshAccounts":[],"killProcess":["pid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getClusters":[],"getLogByName":["id","name"],"launchCluster":["id","uuid","search_for_java"],"getGameVersions":[],"getUsers":[],"getUsersFromAuthor":["provider","author"],"fetchLoggedInProfile":["access_token"],"isClusterRunning":["cluster_id"],"getProcessLogTail":["id","max_lines"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"removeCape":["access_token"],"convertUsernameUUID":["username_uuid"],"open":["input"],"searchPackages":["provider","query"],"setDiscordRPCMessage":["message"],"getScreenshots":["id"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"readSettings":[],"refreshAccount":["uuid"],"createCluster":["options"],"syncCluster":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"updateClusterById":["id","request"],"getRunningProcesses":[],"getGlobalProfile":[],"getDefaultUser":["fallback"],"getLinkedPackages":["cluster_id"],"installModpack":["modpack","cluster_id"],"getWorlds":["id"],"getRunningProcessesByClusterId":["cluster_id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"removeCluster":["id"],"getLoadersForVersion":["mc_version"],"removeUser":["uuid"],"removePackage":["cluster_id","package_hash"],"fetchMinecraftProfile":["uuid"],"setClusterStage":["id","stage"],"getUser":["uuid"],"updateClusterProfile":["name","profile"],"openMsaLogin":[],"getPackage":["provider","slug"],"getLogs":["id"],"createSettingsProfile":["name"],"getPackageBody":["provider","body"],"setDefaultUser":["uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"changeCape":["access_token","cape_uuid"]}', 'debug':'{"getFullDebugInfo":[],"getLocale":[],"getBuildTimestamp":[],"getType":[],"getPlatform":[],"getFamily":[],"hasDevSettings":[],"getGitCommitHash":[],"getPackageVersion":[],"isInDev":[],"getOsVersion":[],"getFullDebugInfoParsedString":[],"getArch":[],"openDevTools":[],"getFullDebugInfoParsed":[],"getOsDistro":[]}' }
|
const ARGS_MAP = { 'oneclient':'{"refreshArt":["path"],"getVersions":[],"getBundlesFor":["cluster_id"],"updateBundlePackages":["cluster_id"],"copyClusterContent":["source_id","target_id"],"isBundleSyncing":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"checkForUpdate":[],"installUpdate":[],"getClustersGroupedByMajor":[],"cacheArt":["path"],"extractBundleOverrides":["bundle_path","cluster_id"]}', 'core':'{"searchPackages":["provider","query"],"updateClusterById":["id","request"],"fetchMinecraftProfile":["uuid"],"getLogs":["id"],"launchCluster":["id","uuid","search_for_java"],"getClusters":[],"getProcessLogTail":["id","max_lines"],"killProcess":["pid"],"getRunningProcesses":[],"setClusterStage":["id","stage"],"getGlobalProfile":[],"getLoadersForVersion":["mc_version"],"getGameVersions":[],"getMultiplePackages":["provider","slugs"],"createSettingsProfile":["name"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getPackage":["provider","slug"],"syncCluster":["cluster_id"],"getLinkedPackages":["cluster_id"],"open":["input"],"readSettings":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"refreshAccounts":[],"getClusterById":["id"],"removeUser":["uuid"],"getDefaultUser":["fallback"],"removeCluster":["id"],"createCluster":["options"],"getWorlds":["id"],"getLogByName":["id","name"],"updateClusterProfile":["name","profile"],"setDefaultUser":["uuid"],"getUsers":[],"getPackageBody":["provider","body"],"removePackage":["cluster_id","package_hash"],"fetchLoggedInProfile":["access_token"],"setDiscordRPCMessage":["message"],"getUsersFromAuthor":["provider","author"],"isClusterRunning":["cluster_id"],"refreshAccount":["uuid"],"writeSettings":["setting"],"installModpack":["modpack","cluster_id"],"getProfileOrDefault":["name"],"removeCape":["access_token"],"getRunningProcessesByClusterId":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"getScreenshots":["id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getUser":["uuid"],"openMsaLogin":[],"changeSkin":["access_token","skin_url","skin_variant"],"convertUsernameUUID":["username_uuid"],"changeCape":["access_token","cape_uuid"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'debug':'{"getType":[],"getArch":[],"getFullDebugInfoParsedString":[],"getGitCommitHash":[],"getFamily":[],"getFullDebugInfo":[],"openDevTools":[],"getOsVersion":[],"getPackageVersion":[],"getPlatform":[],"getFullDebugInfoParsed":[],"isInDev":[],"getBuildTimestamp":[],"getLocale":[],"hasDevSettings":[],"getOsDistro":[]}' }
|
||||||
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
|
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
|
||||||
message: (event: MessagePayload) => Promise<void>,
|
message: (event: MessagePayload) => Promise<void>,
|
||||||
process: (event: ProcessPayload) => Promise<void> },
|
process: (event: ProcessPayload) => Promise<void> },
|
||||||
'debug': { openDevTools: () => Promise<void>,
|
|
||||||
isInDev: () => Promise<boolean>,
|
|
||||||
getArch: () => Promise<string>,
|
|
||||||
getFamily: () => Promise<string>,
|
|
||||||
getLocale: () => Promise<string>,
|
|
||||||
getType: () => Promise<string>,
|
|
||||||
getPlatform: () => Promise<string>,
|
|
||||||
getOsVersion: () => Promise<string>,
|
|
||||||
getOsDistro: () => Promise<string>,
|
|
||||||
getGitCommitHash: () => Promise<string>,
|
|
||||||
getBuildTimestamp: () => Promise<string>,
|
|
||||||
getPackageVersion: () => Promise<string>,
|
|
||||||
getFullDebugInfo: () => Promise<DebugInfoData>,
|
|
||||||
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
|
|
||||||
getFullDebugInfoParsedString: () => Promise<string>,
|
|
||||||
hasDevSettings: () => Promise<boolean> },
|
|
||||||
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
|
|
||||||
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
|
|
||||||
getVersions: () => Promise<OnlineClusterManifest>,
|
|
||||||
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
|
|
||||||
checkForUpdate: () => Promise<Update | null>,
|
|
||||||
installUpdate: () => Promise<null>,
|
|
||||||
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
|
|
||||||
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
|
|
||||||
isBundleSyncing: () => Promise<boolean>,
|
|
||||||
cacheArt: (path: string) => Promise<string>,
|
|
||||||
refreshArt: (path: string) => Promise<null>,
|
|
||||||
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> },
|
|
||||||
'core': { getClusters: () => Promise<ClusterModel[]>,
|
'core': { getClusters: () => Promise<ClusterModel[]>,
|
||||||
getClusterById: (id: number) => Promise<ClusterModel | null>,
|
getClusterById: (id: number) => Promise<ClusterModel | null>,
|
||||||
removeCluster: (id: number) => Promise<null>,
|
removeCluster: (id: number) => Promise<null>,
|
||||||
|
|
@ -412,7 +384,35 @@ convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
|
||||||
setDiscordRPCMessage: (message: string) => Promise<null>,
|
setDiscordRPCMessage: (message: string) => Promise<null>,
|
||||||
open: (input: string) => Promise<null> },
|
open: (input: string) => Promise<null> },
|
||||||
'folders': { fromCluster: (folderName: string) => Promise<string>,
|
'folders': { fromCluster: (folderName: string) => Promise<string>,
|
||||||
openCluster: (folderName: string) => Promise<null> } };
|
openCluster: (folderName: string) => Promise<null> },
|
||||||
|
'debug': { openDevTools: () => Promise<void>,
|
||||||
|
isInDev: () => Promise<boolean>,
|
||||||
|
getArch: () => Promise<string>,
|
||||||
|
getFamily: () => Promise<string>,
|
||||||
|
getLocale: () => Promise<string>,
|
||||||
|
getType: () => Promise<string>,
|
||||||
|
getPlatform: () => Promise<string>,
|
||||||
|
getOsVersion: () => Promise<string>,
|
||||||
|
getOsDistro: () => Promise<string>,
|
||||||
|
getGitCommitHash: () => Promise<string>,
|
||||||
|
getBuildTimestamp: () => Promise<string>,
|
||||||
|
getPackageVersion: () => Promise<string>,
|
||||||
|
getFullDebugInfo: () => Promise<DebugInfoData>,
|
||||||
|
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
|
||||||
|
getFullDebugInfoParsedString: () => Promise<string>,
|
||||||
|
hasDevSettings: () => Promise<boolean> },
|
||||||
|
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
|
||||||
|
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
|
||||||
|
getVersions: () => Promise<OnlineClusterManifest>,
|
||||||
|
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
|
||||||
|
checkForUpdate: () => Promise<Update | null>,
|
||||||
|
installUpdate: () => Promise<null>,
|
||||||
|
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
|
||||||
|
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
|
||||||
|
isBundleSyncing: () => Promise<boolean>,
|
||||||
|
cacheArt: (path: string) => Promise<string>,
|
||||||
|
refreshArt: (path: string) => Promise<null>,
|
||||||
|
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> } };
|
||||||
|
|
||||||
|
|
||||||
export type { InferCommandOutput }
|
export type { InferCommandOutput }
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,39 @@ export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record<s
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadModsParallel(items: ModDataArray, limit: number, fn: (mod: ModData, index: number) => Promise<void>) {
|
export function buildModDataArray(modsPerCluster: Record<string, Array<ModWithBundle>>): ModDataArray {
|
||||||
|
const modsList: ModDataArray = [];
|
||||||
|
for (const [clusterId, modsWithBundle] of Object.entries(modsPerCluster))
|
||||||
|
for (const { file: mod, bundleName } of modsWithBundle) {
|
||||||
|
if ('External' in mod.kind)
|
||||||
|
modsList.push({
|
||||||
|
name: getModMetaDataName(mod),
|
||||||
|
clusterId: Number(clusterId),
|
||||||
|
managed: false,
|
||||||
|
package: mod.kind.External,
|
||||||
|
bundleName,
|
||||||
|
fileKind: mod.kind,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('Managed' in mod.kind) {
|
||||||
|
const [pkg, version] = mod.kind.Managed;
|
||||||
|
modsList.push({
|
||||||
|
name: getModMetaDataName(mod),
|
||||||
|
clusterId: Number(clusterId),
|
||||||
|
managed: true,
|
||||||
|
provider: pkg.provider,
|
||||||
|
id: pkg.id,
|
||||||
|
versionId: version.version_id,
|
||||||
|
dependencies: version.dependencies,
|
||||||
|
bundleName,
|
||||||
|
fileKind: mod.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadModsParallel(items: ModDataArray, limit: number, fn: (mod: ModData, index: number) => Promise<void>) {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const workers = Array.from({ length: limit }).fill(null).map(async () => {
|
const workers = Array.from({ length: limit }).fill(null).map(async () => {
|
||||||
while (index < items.length) {
|
while (index < items.length) {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg';
|
import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg';
|
||||||
import HypixelSkyblockHub from '@/assets/backgrounds/HypixelSkyblockHub.png';
|
import HypixelSkyblockHub from '@/assets/backgrounds/HypixelSkyblockHub.png';
|
||||||
import MinecraftBuilding from '@/assets/backgrounds/MinecraftBuilding.png';
|
|
||||||
|
|
||||||
const GameBackgrounds = {
|
const GameBackgrounds = {
|
||||||
HypixelSkyblockHub,
|
HypixelSkyblockHub,
|
||||||
CavesAndCliffs,
|
CavesAndCliffs,
|
||||||
MinecraftBuilding,
|
MinecraftBuilding: HypixelSkyblockHub,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GameBackgroundName = keyof typeof GameBackgrounds;
|
export type GameBackgroundName = keyof typeof GameBackgrounds;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import type { MinecraftAuthErrorInfo } from './minecraft-auth-errors';
|
import type { MinecraftAuthErrorInfo } from './minecraft-auth-errors';
|
||||||
import { Overlay } from '@/components';
|
import { Overlay } from '@/components';
|
||||||
import { bindings } from '@/main';
|
import { bindings } from '@/main';
|
||||||
|
|
@ -32,6 +33,42 @@ export function isMinecraftAuthError(error: unknown): boolean {
|
||||||
return extractXboxErrorCode(error) !== null;
|
return extractXboxErrorCode(error) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStepWithLinks(step: string): Array<ReactNode> {
|
||||||
|
const anchorRegex = /<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/g;
|
||||||
|
const nodes: Array<ReactNode> = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match = anchorRegex.exec(step);
|
||||||
|
|
||||||
|
while (match) {
|
||||||
|
const fullMatch = match[0];
|
||||||
|
const href = match[1];
|
||||||
|
const label = match[2];
|
||||||
|
|
||||||
|
if (match.index > lastIndex)
|
||||||
|
nodes.push(step.slice(lastIndex, match.index));
|
||||||
|
|
||||||
|
nodes.push(
|
||||||
|
<a
|
||||||
|
className="text-brand-primary underline"
|
||||||
|
href={href}
|
||||||
|
key={`${href}-${match.index}`}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = match.index + fullMatch.length;
|
||||||
|
match = anchorRegex.exec(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < step.length)
|
||||||
|
nodes.push(step.slice(lastIndex));
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
function MatchedErrorContent({
|
function MatchedErrorContent({
|
||||||
matchedError,
|
matchedError,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -52,10 +89,7 @@ function MatchedErrorContent({
|
||||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-brand-primary/20 text-brand-primary text-xs flex items-center justify-center font-medium mt-0.5">
|
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-brand-primary/20 text-brand-primary text-xs flex items-center justify-center font-medium mt-0.5">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-fg-secondary text-sm">{renderStepWithLinks(step)}</span>
|
||||||
className="text-fg-secondary text-sm [&_a]:text-brand-primary [&_a]:underline"
|
|
||||||
dangerouslySetInnerHTML={{ __html: step }}
|
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,17 @@ import { useCopyDebugInfo } from '@/hooks/useDebugInfo';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { bindings } from '@/main';
|
import { bindings } from '@/main';
|
||||||
import { Button } from '@onelauncher/common/components';
|
import { Button } from '@onelauncher/common/components';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
export function SuperSecretDevOptions() {
|
export function SuperSecretDevOptions() {
|
||||||
const { createSetting } = useSettings();
|
const { createSetting, setSetting } = useSettings();
|
||||||
const copyDebugInfo = useCopyDebugInfo();
|
const copyDebugInfo = useCopyDebugInfo();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSkipOnboarding = () => {
|
||||||
|
setSetting('seen_onboarding', true);
|
||||||
|
navigate({ to: '/app' });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay.Dialog>
|
<Overlay.Dialog>
|
||||||
|
|
@ -31,9 +37,7 @@ export function SuperSecretDevOptions() {
|
||||||
<Button size="normal">Debug Page</Button>
|
<Button size="normal">Debug Page</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link to="/onboarding/finished">
|
<Button onPress={handleSkipOnboarding} size="normal">Skip Onboarding</Button>
|
||||||
<Button size="normal">Skip Onboarding</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export function Toasts() {
|
||||||
if (unlisten)
|
if (unlisten)
|
||||||
unlisten();
|
unlisten();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContainer
|
<ToastContainer
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const ONBOARDING_TIPS_BACKUP = [
|
||||||
|
'418 I\'m a teapot',
|
||||||
|
'Floors 8 through 10 of the Catacombs were originally planned for release, but portions of it were later merged into Floor 7 and its Master Mode counterpart.',
|
||||||
|
'At one point, Feather Client was willing to pay $300 USD to add Skytils, a popular SkyBlock mod.',
|
||||||
|
'Grass is a plant with narrow leaves growing from the base. [...]\nGrasses are monocotyledon, herbaceous plants.',
|
||||||
|
'In Canada, Santa\'s postal code is H0H 0H0.',
|
||||||
|
'Guvf vf abg na vzcbegnag fragrapr.',
|
||||||
|
'Vs lbh qrpvcure guvf, lbh fubhyq yvfgra gb Mrgihr\'f zhfvp.',
|
||||||
|
'Try to do something creative! It could be music, art, coding...\nYou won\'t know how much you like it until you try it, so give it a shot :)',
|
||||||
|
'At one point in Minecraft, there was a rare chance of Snow Golems naturally spawning.',
|
||||||
|
'W[h]yvest',
|
||||||
|
'RUMOUR. Open your ears; 9r"5j5&?OWTY Z0d',
|
||||||
|
'Make sure to drink water! You never know if you might be dehydrated.',
|
||||||
|
'People asking you to "test their mod" are likely trying to hack you.\nThe only ones you can trust are ones widely trusted by the community.',
|
||||||
|
'Have a great day!',
|
||||||
|
'You can sleep through a thunderstorm, even during the day',
|
||||||
|
];
|
||||||
|
|
@ -4,7 +4,7 @@ import { bindings } from '@/main';
|
||||||
import { useToast } from '@/utils/toast';
|
import { useToast } from '@/utils/toast';
|
||||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
import { register } from '@tauri-apps/plugin-global-shortcut';
|
import { register } from '@tauri-apps/plugin-global-shortcut';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
export type DebugInfoArray = Array<DebugInfoParsedLine>;
|
export type DebugInfoArray = Array<DebugInfoParsedLine>;
|
||||||
export function useDebugInfo(): DebugInfoArray {
|
export function useDebugInfo(): DebugInfoArray {
|
||||||
|
|
@ -38,16 +38,16 @@ export function useCopyDebugInfo() {
|
||||||
|
|
||||||
export function useDebugKeybind() {
|
export function useDebugKeybind() {
|
||||||
const copyDebugInfo = useCopyDebugInfo();
|
const copyDebugInfo = useCopyDebugInfo();
|
||||||
const handleKeybind = (event: ShortcutEvent) => {
|
const handleKeybind = useCallback((event: ShortcutEvent) => {
|
||||||
if (event.state !== 'Pressed')
|
if (event.state !== 'Pressed')
|
||||||
return;
|
return;
|
||||||
copyDebugInfo();
|
copyDebugInfo();
|
||||||
};
|
}, [copyDebugInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await register('CommandOrControl+Shift+D', handleKeybind);
|
await register('CommandOrControl+Shift+D', handleKeybind);
|
||||||
await register('Alt+F12', handleKeybind);
|
await register('Alt+F12', handleKeybind);
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, [handleKeybind]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export const ResolvedPathNames: Record<URLPath, string> = {
|
||||||
'/onboarding/preferences/versionCategory': 'Preparing OneClient',
|
'/onboarding/preferences/versionCategory': 'Preparing OneClient',
|
||||||
'/onboarding/preferences': 'Preparing OneClient',
|
'/onboarding/preferences': 'Preparing OneClient',
|
||||||
'/onboarding/account': 'Preparing OneClient',
|
'/onboarding/account': 'Preparing OneClient',
|
||||||
'/onboarding/finished': 'Preparing OneClient',
|
'/onboarding/downloading': 'Preparing OneClient',
|
||||||
'/onboarding/language': 'Preparing OneClient',
|
'/onboarding/language': 'Preparing OneClient',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export function useLaunchCluster(clusterId?: number | undefined | null) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clusterId, navigate],
|
[clusterId, navigate, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const forceLaunch = useCallback(async () => {
|
const forceLaunch = useCallback(async () => {
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,28 @@ import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const settings = useCommandSuspense(['readSettings'], bindings.core.readSettings);
|
const settings = useCommandSuspense(['readSettings'], bindings.core.readSettings);
|
||||||
const setting = useMemo(() => <TKey extends keyof Settings>(key: TKey) => settings.data[key], [settings]);
|
const setting = useMemo(() => {
|
||||||
|
return function setting<TKey extends keyof Settings>(key: TKey) {
|
||||||
|
return settings.data[key];
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
const setSetting = useMemo(() => <TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) => {
|
const setSetting = useMemo(() => {
|
||||||
bindings.core.writeSettings({
|
return function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) {
|
||||||
...settings.data,
|
bindings.core.writeSettings({
|
||||||
[key]: value,
|
...settings.data,
|
||||||
});
|
[key]: value,
|
||||||
settings.refetch();
|
});
|
||||||
|
settings.refetch();
|
||||||
|
};
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
type Setter<TKey extends keyof Settings> = (value: Settings[TKey]) => void;
|
type Setter<TKey extends keyof Settings> = (value: Settings[TKey]) => void;
|
||||||
|
|
||||||
const createSetting = useMemo(() => <TKey extends keyof Settings>(key: TKey): [Settings[TKey], Setter<TKey>] => {
|
const createSetting = useMemo(() => {
|
||||||
return [setting(key), value => setSetting(key, value)];
|
return function createSetting<TKey extends keyof Settings>(key: TKey): [Settings[TKey], Setter<TKey>] {
|
||||||
|
return [setting(key), value => setSetting(key, value)];
|
||||||
|
};
|
||||||
}, [setting, setSetting]);
|
}, [setting, setSetting]);
|
||||||
|
|
||||||
return { setting, setSetting, createSetting };
|
return { setting, setSetting, createSetting };
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import useMigrationStore from '@/stores/migrationStore';
|
||||||
import { TitleCase } from '@/utils/string';
|
import { TitleCase } from '@/utils/string';
|
||||||
import { ToastPositions, ToastTypes, useToast } from '@/utils/toast';
|
import { ToastPositions, ToastTypes, useToast } from '@/utils/toast';
|
||||||
import { Button, Dropdown, TextField } from '@onelauncher/common/components';
|
import { Button, Dropdown, TextField } from '@onelauncher/common/components';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { dataDir, join } from '@tauri-apps/api/path';
|
import { dataDir, join } from '@tauri-apps/api/path';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -170,6 +170,7 @@ function Settings() {
|
||||||
function Other() {
|
function Other() {
|
||||||
const [launcherDir, setLauncherDir] = useState('');
|
const [launcherDir, setLauncherDir] = useState('');
|
||||||
const openMigrationGui = useMigrationStore(state => state.openForDebug);
|
const openMigrationGui = useMigrationStore(state => state.openForDebug);
|
||||||
|
const navigate = useNavigate({ from: '/app/debug' });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
setLauncherDir(await join(await dataDir(), 'OneClient'));
|
setLauncherDir(await join(await dataDir(), 'OneClient'));
|
||||||
|
|
@ -191,6 +192,7 @@ function Other() {
|
||||||
<h1 className="text-3xl font-semibold">Other</h1>
|
<h1 className="text-3xl font-semibold">Other</h1>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
<Button onPress={bindings.debug.openDevTools} size="normal">Open Dev Tools</Button>
|
<Button onPress={bindings.debug.openDevTools} size="normal">Open Dev Tools</Button>
|
||||||
|
<Button onPress={() => navigate({ to: '/onboarding' })} size="normal">Open Onboarding</Button>
|
||||||
<Button onPress={openMigrationGui} size="normal">Open Migration GUI</Button>
|
<Button onPress={openMigrationGui} size="normal">Open Migration GUI</Button>
|
||||||
<Button onClick={openLauncherDir} size="normal">Open Launcher Data</Button>
|
<Button onClick={openLauncherDir} size="normal">Open Launcher Data</Button>
|
||||||
<Button onClick={logRunningProcesses} size="normal">Log Running Processes to frontend</Button>
|
<Button onClick={logRunningProcesses} size="normal">Log Running Processes to frontend</Button>
|
||||||
|
|
|
||||||
198
apps/oneclient/frontend/src/routes/onboarding/downloading.tsx
Normal file
198
apps/oneclient/frontend/src/routes/onboarding/downloading.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import type { ModData } from '@/components';
|
||||||
|
import { buildModDataArray, downloadModsParallel, isManagedMod } from '@/components';
|
||||||
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
|
import { bindings } from '@/main';
|
||||||
|
import useDownloadStore from '@/stores/downloadStore';
|
||||||
|
import { getCachedOnboardingTips, loadOnboardingTips } from '@/utils/onboardingFunFacts';
|
||||||
|
import { useCommandMut } from '@onelauncher/common';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/onboarding/downloading')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navbar in onboarding is h-20 = 80px
|
||||||
|
const NAVBAR_HEIGHT = 80;
|
||||||
|
// How long the fade-out takes before navigating to /app
|
||||||
|
const FADE_DURATION_MS = 700;
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { modsPerCluster } = useDownloadStore();
|
||||||
|
const clearStore = useDownloadStore(s => s.clear);
|
||||||
|
const { setSetting } = useSettings();
|
||||||
|
const [tips, setTips] = useState<Array<string>>(getCachedOnboardingTips);
|
||||||
|
|
||||||
|
const tip = useMemo(() => tips[Math.floor(Math.random() * tips.length)], [tips]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadTips = async () => {
|
||||||
|
try {
|
||||||
|
const loadedTips = await loadOnboardingTips();
|
||||||
|
if (!cancelled && loadedTips.length > 0)
|
||||||
|
setTips(loadedTips);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('[onboarding/downloading] Falling back to backup fun facts.', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadTips();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Only redirect on initial mount if store was already empty (safety net for direct navigation).
|
||||||
|
// Must NOT re-run when clearStore() empties the store at the end of downloads.
|
||||||
|
const initiallyEmpty = useRef(Object.keys(modsPerCluster).length === 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initiallyEmpty.current)
|
||||||
|
navigate({ to: '/onboarding/preferences/versionCategory' });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const mods = useMemo(() => buildModDataArray(modsPerCluster), [modsPerCluster]);
|
||||||
|
|
||||||
|
const [downloadedMods, setDownloadedMods] = useState(0);
|
||||||
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
|
const [isLeaving, setIsLeaving] = useState(false);
|
||||||
|
const hasStarted = useRef(false);
|
||||||
|
|
||||||
|
const percentage = mods.length > 0 ? Math.round((downloadedMods / mods.length) * 100) : 0;
|
||||||
|
|
||||||
|
const download = useCommandMut(async (mod: ModData) => {
|
||||||
|
if (isManagedMod(mod)) {
|
||||||
|
if (mod.dependencies.length > 0)
|
||||||
|
for (const dependency of mod.dependencies) {
|
||||||
|
const cluster = await bindings.core.getClusterById(mod.clusterId);
|
||||||
|
if (!cluster)
|
||||||
|
continue;
|
||||||
|
if (dependency.dependency_type === 'required') {
|
||||||
|
const slug = dependency.project_id ?? '';
|
||||||
|
const versions = await bindings.core.getPackageVersions(mod.provider, slug, cluster.mc_version, cluster.mc_loader, 0, 1);
|
||||||
|
if (versions.items.length !== 0)
|
||||||
|
await bindings.core.downloadPackage(mod.provider, slug, versions.items[0].version_id, cluster.id, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mod.bundleName)
|
||||||
|
await bindings.oneclient.downloadPackageFromBundle(mod.fileKind, mod.clusterId, mod.bundleName, true);
|
||||||
|
else
|
||||||
|
await bindings.core.downloadPackage(mod.provider, mod.id, mod.versionId, mod.clusterId, true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (mod.bundleName)
|
||||||
|
await bindings.oneclient.downloadPackageFromBundle(mod.fileKind, mod.clusterId, mod.bundleName, true);
|
||||||
|
else
|
||||||
|
await bindings.core.downloadExternalPackage(mod.package, mod.clusterId, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasStarted.current || mods.length === 0)
|
||||||
|
return;
|
||||||
|
hasStarted.current = true;
|
||||||
|
|
||||||
|
const downloadAll = async () => {
|
||||||
|
await downloadModsParallel(mods, 10, async (mod) => {
|
||||||
|
try {
|
||||||
|
await download.mutateAsync(mod);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
// Log the full Tauri error object so we can see what went wrong
|
||||||
|
console.error(
|
||||||
|
`[OneClient] Failed to download mod "${mod.name}" (cluster ${mod.clusterId}):`,
|
||||||
|
JSON.stringify(e),
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
// Always increment even on error so progress still moves forward
|
||||||
|
setDownloadedMods(prev => prev + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setIsComplete(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Catch-all: if downloadAll itself rejects for any unexpected reason,
|
||||||
|
// still mark complete so the user isn't stuck on this screen.
|
||||||
|
downloadAll().catch((e) => {
|
||||||
|
console.error('[OneClient] Unexpected error in downloadAll, forcing completion:', JSON.stringify(e), e);
|
||||||
|
setIsComplete(true);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- download is not stable
|
||||||
|
}, [mods]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isComplete)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Brief pause at 100%, then fade out, then navigate straight to /app.
|
||||||
|
const pauseTimer = setTimeout(() => {
|
||||||
|
setIsLeaving(true);
|
||||||
|
|
||||||
|
const fadeTimer = setTimeout(() => {
|
||||||
|
// Mark onboarding as seen (previously done by finished.tsx)
|
||||||
|
setSetting('seen_onboarding', true);
|
||||||
|
// Navigate first, then clear the store to avoid the empty-store redirect firing
|
||||||
|
navigate({ to: '/app' });
|
||||||
|
clearStore();
|
||||||
|
}, FADE_DURATION_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(fadeTimer);
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => clearTimeout(pauseTimer);
|
||||||
|
}, [isComplete, clearStore, navigate, setSetting]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col px-12 pb-12 transition-opacity"
|
||||||
|
style={{
|
||||||
|
// min-height fills the screen below the navbar without relying on h-full,
|
||||||
|
// which collapses inside the motion.div wrapper that has no explicit height.
|
||||||
|
minHeight: `calc(100vh - ${NAVBAR_HEIGHT}px)`,
|
||||||
|
paddingTop: '1rem',
|
||||||
|
opacity: isLeaving ? 0 : 1,
|
||||||
|
transitionDuration: `${FADE_DURATION_MS}ms`,
|
||||||
|
transitionTimingFunction: 'ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top left: title */}
|
||||||
|
<h1 className="text-4xl font-bold text-fg-primary">
|
||||||
|
Downloading...
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Spacer — pushes bottom content to the bottom */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Bottom row: percentage+bar on left, tip on right */}
|
||||||
|
<div className="flex items-end justify-between gap-12">
|
||||||
|
{/* Left: percentage + progress bar */}
|
||||||
|
<div className="flex flex-col gap-2 w-64">
|
||||||
|
<span className="text-2xl font-semibold text-fg-primary">
|
||||||
|
{percentage}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
<div className="w-full h-0.5 rounded-full bg-white/20">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-white transition-all duration-300"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: tip */}
|
||||||
|
<div className="max-w-xs text-right">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-widest text-fg-secondary mb-1">Tip</p>
|
||||||
|
<p className="text-sm text-fg-secondary whitespace-pre-line">{tip}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
|
||||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/onboarding/finished')({
|
|
||||||
component: RouteComponent,
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const { setSetting } = useSettings();
|
|
||||||
setSetting('seen_onboarding', true);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to="/app">
|
|
||||||
<div className="flex flex-col h-full px-12">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold mb-2">Finished!</h1>
|
|
||||||
<p className="text-slate-400 text-lg mb-2">Thank you for using OneClient</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex flex-row gap-2">
|
|
||||||
<p className="text-slate-500 text-lg mb-2">Click anywhere to exit</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import type { ClusterModel, ModpackArchive, ModpackFile, OnlineClusterManifest } from '@/bindings.gen';
|
import type { ClusterModel, ModpackArchive, ModpackFile, OnlineClusterManifest } from '@/bindings.gen';
|
||||||
import type { DownloadModsRef, ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components';
|
import type { ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components';
|
||||||
import { DownloadMods, ModCardContext, ModList, Overlay } from '@/components';
|
import { ModCardContext, ModList, Overlay } from '@/components';
|
||||||
import { useCachedImage } from '@/hooks/useCachedImage';
|
import { useCachedImage } from '@/hooks/useCachedImage';
|
||||||
import { bindings } from '@/main';
|
import { bindings } from '@/main';
|
||||||
import { OnboardingNavigation } from '@/routes/onboarding/route';
|
import { OnboardingNavigation } from '@/routes/onboarding/route';
|
||||||
|
import useDownloadStore from '@/stores/downloadStore';
|
||||||
import { getOnlineClusterForVersion, parseMcVersion } from '@/utils/versionMap';
|
import { getOnlineClusterForVersion, parseMcVersion } from '@/utils/versionMap';
|
||||||
import { useCommandSuspense } from '@onelauncher/common';
|
import { useCommandSuspense } from '@onelauncher/common';
|
||||||
import { Button } from '@onelauncher/common/components';
|
import { Button } from '@onelauncher/common/components';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { DotsVerticalIcon } from '@untitled-theme/icons-react';
|
import { DotsVerticalIcon } from '@untitled-theme/icons-react';
|
||||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Button as AriaButton } from 'react-aria-components';
|
import { Button as AriaButton } from 'react-aria-components';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
|
@ -163,27 +164,24 @@ function RouteComponent() {
|
||||||
|
|
||||||
const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0);
|
const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0);
|
||||||
|
|
||||||
const downloadModsRef = useRef<DownloadModsRef>(null);
|
const setDownloadData = useDownloadStore(s => s.setDownloadData);
|
||||||
const wrappedRef = useRef<DownloadModsRef>(null);
|
|
||||||
|
|
||||||
useImperativeHandle(wrappedRef, () => ({
|
const handleBeforeNext = async () => {
|
||||||
async openDownloadDialog(nextPath?: string) {
|
for (const [clusterId, bundles] of Object.entries(bundlesPerCluster))
|
||||||
for (const [clusterId, bundles] of Object.entries(bundlesPerCluster))
|
for (const bundle of bundles) {
|
||||||
for (const bundle of bundles) {
|
const selectedMods = modsPerCluster[clusterId] ?? [];
|
||||||
const selectedMods = modsPerCluster[clusterId] ?? [];
|
const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name);
|
||||||
const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name);
|
if (!hasSelectedBundleContent)
|
||||||
if (!hasSelectedBundleContent)
|
continue;
|
||||||
continue;
|
try {
|
||||||
try {
|
await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
|
||||||
await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
downloadModsRef.current?.openDownloadDialog(nextPath);
|
catch (e) {
|
||||||
},
|
console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e);
|
||||||
}), [bundlesPerCluster, modsPerCluster]);
|
}
|
||||||
|
}
|
||||||
|
setDownloadData(modsPerCluster, bundlesPerCluster);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -220,15 +218,11 @@ function RouteComponent() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden">
|
|
||||||
<DownloadMods modsPerCluster={modsPerCluster} ref={downloadModsRef} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<OnboardingNavigation disableNext={!anySelected} ref={wrappedRef} />
|
<OnboardingNavigation disableNext={!anySelected} onBeforeNext={handleBeforeNext} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { DownloadModsRef } from '@/components';
|
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import LauncherLogo from '@/assets/logos/oneclient.svg?react';
|
import LauncherLogo from '@/assets/logos/oneclient.svg?react';
|
||||||
import { GameBackground, LoaderSuspense, MadeBy, NavbarButton, Overlay, Stepper, SuperSecretDevOptions } from '@/components';
|
import { GameBackground, LoaderSuspense, MadeBy, NavbarButton, Overlay, Stepper, SuperSecretDevOptions } from '@/components';
|
||||||
import { prefetchCachedImages } from '@/hooks/useCachedImage';
|
import { prefetchCachedImages } from '@/hooks/useCachedImage';
|
||||||
import { bindings } from '@/main';
|
import { bindings } from '@/main';
|
||||||
|
import { preloadOnboardingTips } from '@/utils/onboardingFunFacts';
|
||||||
import { useCommandSuspense } from '@onelauncher/common';
|
import { useCommandSuspense } from '@onelauncher/common';
|
||||||
import { Button } from '@onelauncher/common/components';
|
import { Button } from '@onelauncher/common/components';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
@ -33,10 +33,12 @@ export const Route = createFileRoute('/onboarding')({
|
||||||
|
|
||||||
const isFirstStep = currentLinearStepIndex === 0;
|
const isFirstStep = currentLinearStepIndex === 0;
|
||||||
const isLastStep = currentLinearStepIndex === LINEAR_ONBOARDING_STEPS.length - 1;
|
const isLastStep = currentLinearStepIndex === LINEAR_ONBOARDING_STEPS.length - 1;
|
||||||
|
const isDownloadingStep = location.pathname === '/onboarding/downloading';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isFirstStep,
|
isFirstStep,
|
||||||
isLastStep,
|
isLastStep,
|
||||||
|
isDownloadingStep,
|
||||||
previousPath:
|
previousPath:
|
||||||
currentLinearStepIndex > 0
|
currentLinearStepIndex > 0
|
||||||
? LINEAR_ONBOARDING_STEPS[currentLinearStepIndex - 1]?.path
|
? LINEAR_ONBOARDING_STEPS[currentLinearStepIndex - 1]?.path
|
||||||
|
|
@ -86,12 +88,13 @@ const ONBOARDING_STEPS: Array<OnboardingStep> = [
|
||||||
title: 'Versions Category',
|
title: 'Versions Category',
|
||||||
hideNavigationButtons: true,
|
hideNavigationButtons: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/onboarding/downloading',
|
||||||
|
title: 'Downloading',
|
||||||
|
hideNavigationButtons: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/onboarding/finished',
|
|
||||||
title: 'Finished',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function getLinearSteps(steps: Array<OnboardingStep>): Array<Omit<OnboardingStep, 'subSteps'>> {
|
function getLinearSteps(steps: Array<OnboardingStep>): Array<Omit<OnboardingStep, 'subSteps'>> {
|
||||||
|
|
@ -126,6 +129,10 @@ function RouteComponent() {
|
||||||
void prefetchCachedImages(artPaths);
|
void prefetchCachedImages(artPaths);
|
||||||
}, [versions.clusters]);
|
}, [versions.clusters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void preloadOnboardingTips();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { currentLinearStepIndex } = Route.useLoaderData();
|
const { currentLinearStepIndex } = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -165,7 +172,7 @@ function RouteComponent() {
|
||||||
function AppShell({
|
function AppShell({
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren) {
|
}: PropsWithChildren) {
|
||||||
const { isFirstStep, currentStepIndex } = Route.useLoaderData();
|
const { isFirstStep, isDownloadingStep, currentStepIndex } = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
|
|
@ -193,7 +200,7 @@ function AppShell({
|
||||||
<MadeBy />
|
<MadeBy />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex-1 flex ${isFirstStep ? '' : 'bg-page'} flex-col relative`}>
|
<div className={`flex-1 flex ${(isFirstStep || isDownloadingStep) ? '' : 'bg-page'} flex-col relative`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -210,13 +217,13 @@ function BackgroundGradient() {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Linear black gradient: left -> right */}
|
{/* Linear black gradient: left -> right */}
|
||||||
{/* <div
|
<div
|
||||||
className="absolute top-0 left-0 w-screen h-screen -z-10"
|
className="absolute top-0 left-0 w-screen h-screen -z-10"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(270deg, rgba(0, 0, 0, 0.00) 35%, rgba(0, 0, 0, 0.60) 87.5%)',
|
background: 'linear-gradient(270deg, rgba(0, 0, 0, 0.00) 35%, rgba(0, 0, 0, 0.60) 87.5%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
{/* Radial black gradient */}
|
{/* Radial black gradient */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -244,18 +251,18 @@ function BackgroundGradient() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingNavigation({ ref, disableNext }: { ref?: React.RefObject<DownloadModsRef | null>; disableNext?: boolean }) {
|
export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNext?: boolean; onBeforeNext?: () => Promise<void> | void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isFirstStep, previousPath, nextPath } = Route.useLoaderData();
|
const { isFirstStep, previousPath, nextPath } = Route.useLoaderData();
|
||||||
|
|
||||||
function handleNextClick() {
|
async function handleNextClick() {
|
||||||
if (disableNext)
|
if (disableNext)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (ref && ref.current !== null)
|
if (onBeforeNext)
|
||||||
ref.current.openDownloadDialog(nextPath ?? '/app');
|
await onBeforeNext();
|
||||||
else
|
|
||||||
navigate({ to: nextPath ?? '/app' });
|
navigate({ to: nextPath ?? '/app' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
23
apps/oneclient/frontend/src/stores/downloadStore.ts
Normal file
23
apps/oneclient/frontend/src/stores/downloadStore.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { ModpackArchive } from '@/bindings.gen';
|
||||||
|
import type { ModWithBundle } from '@/components';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface DownloadStore {
|
||||||
|
modsPerCluster: Record<string, Array<ModWithBundle>>;
|
||||||
|
bundlesPerCluster: Record<number, Array<ModpackArchive>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadStoreActions {
|
||||||
|
setDownloadData: (modsPerCluster: Record<string, Array<ModWithBundle>>, bundlesPerCluster: Record<number, Array<ModpackArchive>>) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDownloadStore = create<DownloadStore & DownloadStoreActions>()(set => ({
|
||||||
|
modsPerCluster: {},
|
||||||
|
bundlesPerCluster: {},
|
||||||
|
|
||||||
|
setDownloadData: (modsPerCluster, bundlesPerCluster) => set({ modsPerCluster, bundlesPerCluster }),
|
||||||
|
clear: () => set({ modsPerCluster: {}, bundlesPerCluster: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useDownloadStore;
|
||||||
62
apps/oneclient/frontend/src/utils/onboardingFunFacts.ts
Normal file
62
apps/oneclient/frontend/src/utils/onboardingFunFacts.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { ONBOARDING_TIPS_BACKUP } from '@/constants/onboardingTipsBackup';
|
||||||
|
|
||||||
|
const FUN_FACTS_URL = 'https://raw.githubusercontent.com/Polyfrost/DataStorage/main/oneclient/funfacts.txt';
|
||||||
|
|
||||||
|
let cachedOnboardingTips: Array<string> | null = null;
|
||||||
|
let onboardingTipsPromise: Promise<Array<string>> | null = null;
|
||||||
|
|
||||||
|
function normalizeLineBreakMarkers(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll('/n', '\n')
|
||||||
|
.replaceAll('\\n', '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFunFacts(text: string): Array<string> {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(normalizeLineBreakMarkers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnboardingTips(): Promise<Array<string>> {
|
||||||
|
const response = await fetch(FUN_FACTS_URL, { cache: 'no-store' });
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Failed to fetch onboarding fun facts: ${response.status}`);
|
||||||
|
|
||||||
|
const tips = parseFunFacts(await response.text());
|
||||||
|
if (tips.length === 0)
|
||||||
|
throw new Error('Fetched onboarding fun facts were empty.');
|
||||||
|
|
||||||
|
return tips;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedOnboardingTips(): Array<string> {
|
||||||
|
return (cachedOnboardingTips ?? ONBOARDING_TIPS_BACKUP).map(normalizeLineBreakMarkers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOnboardingTips(): Promise<Array<string>> {
|
||||||
|
if (cachedOnboardingTips)
|
||||||
|
return cachedOnboardingTips;
|
||||||
|
|
||||||
|
if (!onboardingTipsPromise)
|
||||||
|
onboardingTipsPromise = fetchOnboardingTips()
|
||||||
|
.then((tips) => {
|
||||||
|
cachedOnboardingTips = tips;
|
||||||
|
return tips;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
onboardingTipsPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return onboardingTipsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preloadOnboardingTips(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await loadOnboardingTips();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('[onboarding] Failed to preload fun facts, using backup facts.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue