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"
|
||||
|
||||
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>,
|
||||
message: (event: MessagePayload) => 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[]>,
|
||||
getClusterById: (id: number) => Promise<ClusterModel | null>,
|
||||
removeCluster: (id: number) => Promise<null>,
|
||||
|
|
@ -412,7 +384,35 @@ convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
|
|||
setDiscordRPCMessage: (message: string) => Promise<null>,
|
||||
open: (input: string) => Promise<null> },
|
||||
'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 }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
const workers = Array.from({ length: limit }).fill(null).map(async () => {
|
||||
while (index < items.length) {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ import { twMerge } from 'tailwind-merge';
|
|||
|
||||
import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg';
|
||||
import HypixelSkyblockHub from '@/assets/backgrounds/HypixelSkyblockHub.png';
|
||||
import MinecraftBuilding from '@/assets/backgrounds/MinecraftBuilding.png';
|
||||
|
||||
const GameBackgrounds = {
|
||||
HypixelSkyblockHub,
|
||||
CavesAndCliffs,
|
||||
MinecraftBuilding,
|
||||
MinecraftBuilding: HypixelSkyblockHub,
|
||||
};
|
||||
|
||||
export type GameBackgroundName = keyof typeof GameBackgrounds;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import type { MinecraftAuthErrorInfo } from './minecraft-auth-errors';
|
||||
import { Overlay } from '@/components';
|
||||
import { bindings } from '@/main';
|
||||
|
|
@ -32,6 +33,42 @@ export function isMinecraftAuthError(error: unknown): boolean {
|
|||
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({
|
||||
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">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className="text-fg-secondary text-sm [&_a]:text-brand-primary [&_a]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: step }}
|
||||
/>
|
||||
<span className="text-fg-secondary text-sm">{renderStepWithLinks(step)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,17 @@ import { useCopyDebugInfo } from '@/hooks/useDebugInfo';
|
|||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { bindings } from '@/main';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
|
||||
export function SuperSecretDevOptions() {
|
||||
const { createSetting } = useSettings();
|
||||
const { createSetting, setSetting } = useSettings();
|
||||
const copyDebugInfo = useCopyDebugInfo();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSkipOnboarding = () => {
|
||||
setSetting('seen_onboarding', true);
|
||||
navigate({ to: '/app' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay.Dialog>
|
||||
|
|
@ -31,9 +37,7 @@ export function SuperSecretDevOptions() {
|
|||
<Button size="normal">Debug Page</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/onboarding/finished">
|
||||
<Button size="normal">Skip Onboarding</Button>
|
||||
</Link>
|
||||
<Button onPress={handleSkipOnboarding} size="normal">Skip Onboarding</Button>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export function Toasts() {
|
|||
if (unlisten)
|
||||
unlisten();
|
||||
};
|
||||
}, []);
|
||||
}, [toast]);
|
||||
|
||||
return (
|
||||
<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 { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
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 function useDebugInfo(): DebugInfoArray {
|
||||
|
|
@ -38,16 +38,16 @@ export function useCopyDebugInfo() {
|
|||
|
||||
export function useDebugKeybind() {
|
||||
const copyDebugInfo = useCopyDebugInfo();
|
||||
const handleKeybind = (event: ShortcutEvent) => {
|
||||
const handleKeybind = useCallback((event: ShortcutEvent) => {
|
||||
if (event.state !== 'Pressed')
|
||||
return;
|
||||
copyDebugInfo();
|
||||
};
|
||||
}, [copyDebugInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await register('CommandOrControl+Shift+D', handleKeybind);
|
||||
await register('Alt+F12', handleKeybind);
|
||||
})();
|
||||
}, []);
|
||||
}, [handleKeybind]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const ResolvedPathNames: Record<URLPath, string> = {
|
|||
'/onboarding/preferences/versionCategory': 'Preparing OneClient',
|
||||
'/onboarding/preferences': 'Preparing OneClient',
|
||||
'/onboarding/account': 'Preparing OneClient',
|
||||
'/onboarding/finished': 'Preparing OneClient',
|
||||
'/onboarding/downloading': '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 () => {
|
||||
|
|
|
|||
|
|
@ -5,20 +5,28 @@ import { useEffect, useMemo, useState } from 'react';
|
|||
|
||||
export function useSettings() {
|
||||
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]) => {
|
||||
bindings.core.writeSettings({
|
||||
...settings.data,
|
||||
[key]: value,
|
||||
});
|
||||
settings.refetch();
|
||||
const setSetting = useMemo(() => {
|
||||
return function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) {
|
||||
bindings.core.writeSettings({
|
||||
...settings.data,
|
||||
[key]: value,
|
||||
});
|
||||
settings.refetch();
|
||||
};
|
||||
}, [settings]);
|
||||
|
||||
type Setter<TKey extends keyof Settings> = (value: Settings[TKey]) => void;
|
||||
|
||||
const createSetting = useMemo(() => <TKey extends keyof Settings>(key: TKey): [Settings[TKey], Setter<TKey>] => {
|
||||
return [setting(key), value => setSetting(key, value)];
|
||||
const createSetting = useMemo(() => {
|
||||
return function createSetting<TKey extends keyof Settings>(key: TKey): [Settings[TKey], Setter<TKey>] {
|
||||
return [setting(key), value => setSetting(key, value)];
|
||||
};
|
||||
}, [setting, setSetting]);
|
||||
|
||||
return { setting, setSetting, createSetting };
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import useMigrationStore from '@/stores/migrationStore';
|
|||
import { TitleCase } from '@/utils/string';
|
||||
import { ToastPositions, ToastTypes, useToast } from '@/utils/toast';
|
||||
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 { useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -170,6 +170,7 @@ function Settings() {
|
|||
function Other() {
|
||||
const [launcherDir, setLauncherDir] = useState('');
|
||||
const openMigrationGui = useMigrationStore(state => state.openForDebug);
|
||||
const navigate = useNavigate({ from: '/app/debug' });
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLauncherDir(await join(await dataDir(), 'OneClient'));
|
||||
|
|
@ -191,6 +192,7 @@ function Other() {
|
|||
<h1 className="text-3xl font-semibold">Other</h1>
|
||||
<div className="flex flex-row gap-4">
|
||||
<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 onClick={openLauncherDir} size="normal">Open Launcher Data</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 { DownloadModsRef, ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components';
|
||||
import { DownloadMods, ModCardContext, ModList, Overlay } from '@/components';
|
||||
import type { ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components';
|
||||
import { ModCardContext, ModList, Overlay } from '@/components';
|
||||
import { useCachedImage } from '@/hooks/useCachedImage';
|
||||
import { bindings } from '@/main';
|
||||
import { OnboardingNavigation } from '@/routes/onboarding/route';
|
||||
import useDownloadStore from '@/stores/downloadStore';
|
||||
import { getOnlineClusterForVersion, parseMcVersion } from '@/utils/versionMap';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { DotsVerticalIcon } from '@untitled-theme/icons-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 { twMerge } from 'tailwind-merge';
|
||||
|
||||
|
|
@ -163,27 +164,24 @@ function RouteComponent() {
|
|||
|
||||
const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0);
|
||||
|
||||
const downloadModsRef = useRef<DownloadModsRef>(null);
|
||||
const wrappedRef = useRef<DownloadModsRef>(null);
|
||||
const setDownloadData = useDownloadStore(s => s.setDownloadData);
|
||||
|
||||
useImperativeHandle(wrappedRef, () => ({
|
||||
async openDownloadDialog(nextPath?: string) {
|
||||
for (const [clusterId, bundles] of Object.entries(bundlesPerCluster))
|
||||
for (const bundle of bundles) {
|
||||
const selectedMods = modsPerCluster[clusterId] ?? [];
|
||||
const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name);
|
||||
if (!hasSelectedBundleContent)
|
||||
continue;
|
||||
try {
|
||||
await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e);
|
||||
}
|
||||
const handleBeforeNext = async () => {
|
||||
for (const [clusterId, bundles] of Object.entries(bundlesPerCluster))
|
||||
for (const bundle of bundles) {
|
||||
const selectedMods = modsPerCluster[clusterId] ?? [];
|
||||
const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name);
|
||||
if (!hasSelectedBundleContent)
|
||||
continue;
|
||||
try {
|
||||
await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
|
||||
}
|
||||
downloadModsRef.current?.openDownloadDialog(nextPath);
|
||||
},
|
||||
}), [bundlesPerCluster, modsPerCluster]);
|
||||
catch (e) {
|
||||
console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e);
|
||||
}
|
||||
}
|
||||
setDownloadData(modsPerCluster, bundlesPerCluster);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -220,15 +218,11 @@ function RouteComponent() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden">
|
||||
<DownloadMods modsPerCluster={modsPerCluster} ref={downloadModsRef} />
|
||||
</div>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</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 LauncherLogo from '@/assets/logos/oneclient.svg?react';
|
||||
import { GameBackground, LoaderSuspense, MadeBy, NavbarButton, Overlay, Stepper, SuperSecretDevOptions } from '@/components';
|
||||
import { prefetchCachedImages } from '@/hooks/useCachedImage';
|
||||
import { bindings } from '@/main';
|
||||
import { preloadOnboardingTips } from '@/utils/onboardingFunFacts';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -33,10 +33,12 @@ export const Route = createFileRoute('/onboarding')({
|
|||
|
||||
const isFirstStep = currentLinearStepIndex === 0;
|
||||
const isLastStep = currentLinearStepIndex === LINEAR_ONBOARDING_STEPS.length - 1;
|
||||
const isDownloadingStep = location.pathname === '/onboarding/downloading';
|
||||
|
||||
return {
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isDownloadingStep,
|
||||
previousPath:
|
||||
currentLinearStepIndex > 0
|
||||
? LINEAR_ONBOARDING_STEPS[currentLinearStepIndex - 1]?.path
|
||||
|
|
@ -86,12 +88,13 @@ const ONBOARDING_STEPS: Array<OnboardingStep> = [
|
|||
title: 'Versions Category',
|
||||
hideNavigationButtons: true,
|
||||
},
|
||||
{
|
||||
path: '/onboarding/downloading',
|
||||
title: 'Downloading',
|
||||
hideNavigationButtons: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/onboarding/finished',
|
||||
title: 'Finished',
|
||||
},
|
||||
];
|
||||
|
||||
function getLinearSteps(steps: Array<OnboardingStep>): Array<Omit<OnboardingStep, 'subSteps'>> {
|
||||
|
|
@ -126,6 +129,10 @@ function RouteComponent() {
|
|||
void prefetchCachedImages(artPaths);
|
||||
}, [versions.clusters]);
|
||||
|
||||
useEffect(() => {
|
||||
void preloadOnboardingTips();
|
||||
}, []);
|
||||
|
||||
const { currentLinearStepIndex } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
|
|
@ -165,7 +172,7 @@ function RouteComponent() {
|
|||
function AppShell({
|
||||
children,
|
||||
}: PropsWithChildren) {
|
||||
const { isFirstStep, currentStepIndex } = Route.useLoaderData();
|
||||
const { isFirstStep, isDownloadingStep, currentStepIndex } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
|
|
@ -193,7 +200,7 @@ function AppShell({
|
|||
<MadeBy />
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -210,13 +217,13 @@ function BackgroundGradient() {
|
|||
return (
|
||||
<div className="relative">
|
||||
{/* Linear black gradient: left -> right */}
|
||||
{/* <div
|
||||
<div
|
||||
className="absolute top-0 left-0 w-screen h-screen -z-10"
|
||||
style={{
|
||||
background: 'linear-gradient(270deg, rgba(0, 0, 0, 0.00) 35%, rgba(0, 0, 0, 0.60) 87.5%)',
|
||||
}}
|
||||
>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Radial black gradient */}
|
||||
<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 { isFirstStep, previousPath, nextPath } = Route.useLoaderData();
|
||||
|
||||
function handleNextClick() {
|
||||
async function handleNextClick() {
|
||||
if (disableNext)
|
||||
return;
|
||||
|
||||
if (ref && ref.current !== null)
|
||||
ref.current.openDownloadDialog(nextPath ?? '/app');
|
||||
else
|
||||
navigate({ to: nextPath ?? '/app' });
|
||||
if (onBeforeNext)
|
||||
await onBeforeNext();
|
||||
|
||||
navigate({ to: nextPath ?? '/app' });
|
||||
}
|
||||
|
||||
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