feat: Add Onboarding "tasks"

This commit is contained in:
LynithDev 2024-09-13 22:06:15 +02:00
parent 08f432c3e6
commit fd9a910b12
7 changed files with 201 additions and 78 deletions

1
.gitignore vendored
View file

@ -3,6 +3,7 @@ dev.db
dev.db-journal
.cargo/config.toml
.deps/*
vite.config.ts.timestamp*
# Created by https://www.toptal.com/developers/gitignore/api/node,rust,yarn,linux,macos,windows,intellij+all,rust-analyzer,visualstudiocode,intellij,intellij+iml,git
# Edit at https://www.toptal.com/developers/gitignore?templates=node,rust,yarn,linux,macos,windows,intellij+all,rust-analyzer,visualstudiocode,intellij,intellij+iml,git

View file

@ -1,7 +1,7 @@
import { PROGRAM_INFO, type Settings } from '@onelauncher/client/bindings';
import { useBeforeLeave } from '@solidjs/router';
import { bridge } from '~imports';
import { type Context, createContext, createEffect, type ParentProps, Show, useContext } from 'solid-js';
import type { Settings } from '@onelauncher/client/bindings';
import useCommand from './useCommand';
/**
@ -14,7 +14,7 @@ export function syncSettings(settings: Settings) {
interface SettingsControllerType {
settings: () => Settings;
saveOnLeave: (settings: () => Partial<Settings>) => void;
save: (settings: Settings) => void;
save: (settings: Settings) => Promise<void>;
}
const SettingsContext = createContext() as Context<SettingsControllerType>;
@ -37,14 +37,17 @@ export function SettingsProvider(props: ParentProps) {
});
});
},
save: (settings) => {
bridge.commands.setSettings(settings).then(() => {
syncSettings(settings);
refetch();
});
save: async (settings) => {
await bridge.commands.setSettings(settings);
syncSettings(settings);
await refetch();
},
};
if (PROGRAM_INFO.dev_build)
// @ts-expect-error - Expose settings globally for debugging purposes
window.onelauncherSettings = controller;
return (
<Show when={settings !== undefined && settings() !== undefined}>
<SettingsContext.Provider value={controller}>

View file

@ -2,81 +2,171 @@ import { Route, useLocation, useNavigate } from '@solidjs/router';
import { ChevronLeftIcon, ChevronRightIcon } from '@untitled-theme/icons-solid';
import AnimatedRoutes from '~ui/components/AnimatedRoutes';
import Button from '~ui/components/base/Button';
import type { JSX, ParentProps } from 'solid-js';
import useSettings from '~ui/hooks/useSettings';
import { setAsyncTimeout } from '~utils';
import { type Accessor, type Context, createContext, createEffect, createSignal, type JSX, type ParentProps, useContext } from 'solid-js';
import type { ImportType } from '@onelauncher/client/bindings';
import OnboardingComplete from './OnboardingComplete';
import OnboardingImport from './OnboardingImport';
import OnboardingLanguage from './OnboardingLanguage';
import OnboardingLanguage, { type Language } from './OnboardingLanguage';
import OnboardingSummary from './OnboardingSummary';
import OnboardingWelcome from './OnboardingWelcome';
const basePath = '/onboarding';
const OnboardingSteps = [
['/', OnboardingWelcome],
['/language', OnboardingLanguage],
['/import', OnboardingImport],
['/complete', OnboardingComplete],
['/summary', OnboardingSummary], // Second to last will always be the summary (which is basically a confirmation for all the tasks that are about to be done)
['/complete', OnboardingComplete], // Last will always be a "onelauncher is ready to use" page
] as const;
// eslint-disable-next-line no-restricted-syntax -- -
export const enum OnboardingTaskStage {
NotStarted = 0,
Running = 1,
Completed = 2,
}
interface OnboardingContextType {
setLanguage: (language: Language) => void;
setImportTypes: (types: ImportType[]) => void;
tasksStage: Accessor<OnboardingTaskStage>;
}
const OnboardingContext = createContext() as Context<OnboardingContextType>;
function Onboarding(props: ParentProps) {
const navigate = useNavigate();
const location = useLocation();
const { settings, save: saveSettings } = useSettings();
const step = () => OnboardingSteps.findIndex(([path]) => location.pathname === basePath + path);
const [backButtonEnabled, setBackButtonEnabled] = createSignal(false);
const [forwardButtonEnabled, setForwardButtonEnabled] = createSignal(true);
const canGoBack = () => step() > 0;
const canGoForward = () => step() < OnboardingSteps.length - 1;
const [language, setLanguage] = createSignal<Language>('en');
const [importTypes, setImportTypes] = createSignal<ImportType[]>([]);
const [tasksStage, setTasksStage] = createSignal<OnboardingTaskStage>(OnboardingTaskStage.NotStarted);
const tasksCompleted = () => tasksStage() === OnboardingTaskStage.Completed;
const percentage = () => (step() / (OnboardingSteps.length - 1)) * 100;
const step = () => OnboardingSteps.findIndex(([path]) => (basePath + path).startsWith(location.pathname));
const shallRunTasks = () => !tasksCompleted() && step() === OnboardingSteps.length - 2;
createEffect(() => {
setBackButtonEnabled(step() > 0 && !tasksCompleted());
});
const next = () => {
if (canGoForward())
const isLast = step() === OnboardingSteps.length - 1;
if (isLast)
saveSettings({
...settings(),
onboarding_completed: true,
}).then(() => navigate('/'));
else
navigate(basePath + OnboardingSteps[step() + 1]![0]);
};
const previous = () => {
if (canGoBack())
if (step() > 0)
navigate(basePath + OnboardingSteps[step() - 1]![0]);
};
const percentage = () => (step() / (OnboardingSteps.length - 1)) * 100;
const getButtonText = () => {
if (shallRunTasks())
return 'Setup';
else if (step() === OnboardingSteps.length - 1)
return 'Finish';
else
return 'Next';
};
const runTasks = async () => {
setBackButtonEnabled(false);
setForwardButtonEnabled(false);
setTasksStage(OnboardingTaskStage.Running);
const tasks = [
// eslint-disable-next-line no-console -- -
setAsyncTimeout(() => console.log('Task 1'), 1500),
// eslint-disable-next-line no-console -- -
setAsyncTimeout(() => console.log('Task 2'), 3500),
];
const promises = Object.values(tasks);
const errorCount = (await Promise.allSettled(promises)).filter(value => value.status === 'rejected').length;
if (errorCount > 0)
console.error('Error count:', errorCount); // TODO: Show a toast or something idk
setForwardButtonEnabled(true);
setTasksStage(OnboardingTaskStage.Completed);
};
const ctx: OnboardingContextType = {
setLanguage,
setImportTypes,
tasksStage,
};
return (
<div class="h-full max-h-full w-full flex flex-col items-center justify-center">
<div class="h-0.5 w-full">
<div
class="h-full rounded-lg bg-brand transition-all"
style={{
width: `${percentage()}%`,
}}
/>
</div>
<div class="h-full max-w-280 w-full flex flex-col gap-y-4 p-8">
<div class="h-full w-full">
<AnimatedRoutes>
{props.children}
</AnimatedRoutes>
<OnboardingContext.Provider value={ctx}>
<div class="h-full max-h-full w-full flex flex-col items-center justify-center">
<div class="h-0.5 w-full">
<div
class="h-full rounded-lg bg-brand transition-all"
style={{
width: `${percentage()}%`,
}}
/>
</div>
<div class="z-1 w-full flex flex-1 flex-row items-end justify-end">
<div class="w-1/3 flex flex-row items-stretch gap-x-8 [&>*]:w-full">
<Button
buttonStyle="secondary"
children="Previous"
disabled={!canGoBack()}
iconLeft={<ChevronLeftIcon />}
onClick={previous}
/>
<div class="h-full max-w-280 w-full flex flex-col gap-y-4 p-8">
<div class="h-full w-full">
<AnimatedRoutes>
{props.children}
</AnimatedRoutes>
</div>
<Button
children={canGoForward() ? 'Next' : 'Finish'}
iconRight={<ChevronRightIcon />}
onClick={next}
/>
<div class="z-1 w-full flex flex-1 flex-row items-end justify-end">
<div class="w-1/3 flex flex-row items-stretch gap-x-8 [&>*]:w-full">
<Button
buttonStyle="secondary"
children="Previous"
disabled={!backButtonEnabled()}
iconLeft={<ChevronLeftIcon />}
onClick={previous}
/>
<Button
children={getButtonText()}
disabled={!forwardButtonEnabled()}
iconRight={<ChevronRightIcon />}
onClick={shallRunTasks() ? runTasks : next}
/>
</div>
</div>
</div>
</div>
</div>
</OnboardingContext.Provider>
);
}
Onboarding.useContext = () => {
const ctx = useContext(OnboardingContext);
if (!ctx)
throw new Error('Onboarding context not found');
return ctx;
};
Onboarding.Steps = OnboardingSteps;
Onboarding.Routes = OnboardingSteps.map(([path, component]) => (
<Route component={component} path={path} />

View file

@ -1,11 +1,18 @@
import { useBeforeLeave } from '@solidjs/router';
import Illustration from '~assets/illustrations/onboarding/import_from_others.svg?component-solid';
import Image from '~assets/logos/vanilla.png';
import { LAUNCHER_IMPORT_TYPES } from '~utils';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import { For } from 'solid-js';
import { OnboardingStep } from './Onboarding';
import Onboarding, { OnboardingStep } from './Onboarding';
function OnboardingImport() {
const ctx = Onboarding.useContext();
useBeforeLeave(() => {
ctx.setImportTypes([]);
});
return (
<OnboardingStep
illustration={<Illustration />}

View file

@ -1,36 +1,27 @@
import { TRANSLATION_WEBSITE } from '@onelauncher/client';
import { useBeforeLeave } from '@solidjs/router';
import Illustration from '~assets/illustrations/onboarding/language.svg?component-solid';
import Link from '~ui/components/base/Link';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import { For } from 'solid-js';
import { OnboardingStep } from './Onboarding';
import { createSignal, For } from 'solid-js';
import Onboarding, { OnboardingStep } from './Onboarding';
export const LanguagesList = {
en: ['English', 100],
} as const;
export type Language = keyof typeof LanguagesList;
function OnboardingLanguage() {
const languages: [string, number][] = [
['English', 100],
['German', 21],
['Spanish', 12],
['Polish', 0],
['Russian', 0],
['German', 21],
['Spanish', 12],
['Polish', 0],
['Russian', 0],
['German', 21],
['Spanish', 12],
['Polish', 0],
['Russian', 0],
['German', 21],
['Spanish', 12],
['Polish', 0],
['Russian', 0],
['German', 21],
['Spanish', 12],
['Polish', 0],
['Russian', 0],
];
const ctx = Onboarding.useContext();
const selected = () => languages[0];
const [selected, setSelected] = createSignal<Language>('en');
const getSelectedLanguage = () => LanguagesList[selected()];
useBeforeLeave(() => {
ctx.setLanguage(selected());
});
return (
<OnboardingStep
@ -42,14 +33,15 @@ function OnboardingLanguage() {
<div class="flex flex-col gap-y-2 rounded-lg bg-page-elevated">
<OverlayScrollbarsComponent class="max-h-84 overflow-hidden">
<div class="flex flex-col gap-y-1 p-2">
<For each={languages.sort((a, b) => b[1] - a[1])}>
{language => (
<For each={Object.entries(LanguagesList).sort((a, b) => b[1][1] - a[1][1])}>
{([code, language]) => (
<div
class="flex flex-row items-center rounded-lg px-6 py-5"
classList={{
'bg-brand': language === selected(),
'hover:bg-gray-05 active:bg-gray-10': language !== selected(),
'bg-brand': language === getSelectedLanguage(),
'hover:bg-gray-05 active:bg-gray-10': language !== getSelectedLanguage(),
}}
onClick={() => setSelected(code as Language)}
>
<div class="flex-1 text-lg font-medium">{language[0]}</div>
<div class="flex-1 text-right text-xs">
@ -63,7 +55,7 @@ function OnboardingLanguage() {
</OverlayScrollbarsComponent>
</div>
<div class="w-full flex flex-1 flex-row justify-end">
<div class="w-full flex flex-row justify-end">
<Link class="text-xs" href={TRANSLATION_WEBSITE}>Help translate OneLauncher</Link>
</div>
</div>

View file

@ -0,0 +1,22 @@
import Onboarding from './Onboarding';
function OnboardingSummary() {
const ctx = Onboarding.useContext();
return (
<div class="grid grid-cols-2 h-full w-full flex flex-col items-center justify-center gap-x-16">
<h1 class="text-6xl">
Chosen setup tasks
</h1>
<h3>blah blah.</h3>
<span>
Task Stage =
{ctx.tasksStage()}
</span>
</div>
);
}
export default OnboardingSummary;

View file

@ -2,6 +2,14 @@ import { DurationFormat } from '@formatjs/intl-durationformat';
import { open } from '@tauri-apps/plugin-shell';
import type { Cluster, ImportType, License, Loader, PackageType, Providers, VersionType } from '@onelauncher/client/bindings';
export function setAsyncTimeout<T>(callback: () => T, ms: number): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(callback());
}, ms);
});
}
export function supportsMods(loader?: Cluster | Loader): boolean {
if (loader === undefined)
return false;