feat: context stuff

i dont know what to name this tbh
This commit is contained in:
emirsassan 2025-06-07 15:24:42 +03:00
parent b7cac50020
commit 86c3cc4f40
9 changed files with 297 additions and 128 deletions

View file

@ -41,8 +41,6 @@ export type JavaError = { ParseVersion: [string] } | "Execute" | "MissingJava"
export type LauncherError = { type: "DirError"; data: DirectoryError } | { type: "IOError"; data: IOError } | { type: "IngressError"; data: IngressError } | { type: "JavaError"; data: JavaError } | { type: "CryptoError"; data: CryptoError } | { type: "DiscordError"; data: DiscordError } | { type: "MetadataError"; data: MetadataError } | { type: "ClusterError"; data: ClusterError } | { type: "MinecraftAuthError"; data: MinecraftAuthError } | { type: "ProcessError"; data: ProcessError } | { type: "PackageError"; data: PackageError } | { type: "DaoError"; data: DaoError } | { type: "SerdeError" } | { type: "AnyhowError" } | { type: "DbError" } | { type: "ReqwestError" } | { type: "InterpulseError" } | { type: "RegexError" } | { type: "SemaphoreError" } | { type: "UrlError" } | { type: "TauriError" }
export type LauncherEvent = { Ingress: IngressPayload } | { Message: MessagePayload } | { Process: ProcessPayload }
export type MessageLevel = "Info" | "Warn" | "Error"
export type MessagePayload = { level: MessageLevel; message: string }
@ -92,7 +90,7 @@ export type Process = { pid: number; started_at: string; cluster_id: bigint; pos
export type ProcessError = { HookUnsuccessful: number } | "NoPid"
export type ProcessPayload = { Starting: { command: string } } | { Started: { process: Process } } | { Stopped: { pid: number; exit_code: number } } | { Output: { pid: number; output: string } }
export type ProcessPayload = { type: "Starting"; command: string } | { type: "Started"; process: Process } | { type: "Stopped"; pid: number; exit_code: number } | { type: "Output"; pid: number; output: string }
export type Resolution = { width: number; height: number }
@ -100,36 +98,26 @@ export type SettingProfileModel = { name: string; java_id: bigint | null; res: R
export type SettingsOsExtra = Record<string, never>
const ARGS_MAP = { 'onelauncher':'{"return_error":[],"open_dev_tools":[]}', 'core':'{"get_users":[],"get_user":["uuid"],"get_default_user":["fallback"],"get_cluster":["id"],"create_cluster":["options"],"get_profile_or_default":["name"],"remove_cluster":["id"],"set_default_user":["uuid"],"open_msa_login":[],"get_global_profile":[],"remove_user":["uuid"],"get_clusters":[]}', '':'{"send_event":["event"]}' }
const ARGS_MAP = { 'core':'{"getClusters":[],"getGlobalProfile":[],"getClusterById":["id"],"getProfileOrDefault":["name"],"removeCluster":["id"],"openMsaLogin":[],"createCluster":["options"],"getUser":["uuid"],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"],"launchCluster":["id","uuid"],"getUsers":[],"removeUser":["uuid"]}', 'onelauncher':'{"open_dev_tools":[],"return_error":[]}', 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}' }
export type Router = { 'onelauncher': { return_error: () => Promise<null>,
open_dev_tools: () => Promise<void> },
'core': { get_clusters: () => Promise<Model[]>,
get_cluster: (id: bigint) => Promise<Model | null>,
remove_cluster: (id: bigint) => Promise<null>,
create_cluster: (options: CreateCluster) => Promise<Model>,
get_profile_or_default: (name: string | null) => Promise<SettingProfileModel>,
get_global_profile: () => Promise<SettingProfileModel>,
get_users: () => Promise<MinecraftCredentials[]>,
get_user: (uuid: string) => Promise<MinecraftCredentials | null>,
remove_user: (uuid: string) => Promise<null>,
get_default_user: (fallback: boolean | null) => Promise<MinecraftCredentials | null>,
set_default_user: (uuid: string | null) => Promise<null>,
open_msa_login: () => Promise<MinecraftCredentials | null> },
'': { send_event: (event: LauncherEvent) => Promise<void> } };
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'core': { getClusters: () => Promise<Model[]>,
getClusterById: (id: bigint) => Promise<Model | null>,
removeCluster: (id: bigint) => Promise<null>,
createCluster: (options: CreateCluster) => Promise<Model>,
launchCluster: (id: bigint, uuid: string | null) => Promise<null>,
getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>,
getGlobalProfile: () => Promise<SettingProfileModel>,
getUsers: () => Promise<MinecraftCredentials[]>,
getUser: (uuid: string) => Promise<MinecraftCredentials | null>,
removeUser: (uuid: string) => Promise<null>,
getDefaultUser: (fallback: boolean | null) => Promise<MinecraftCredentials | null>,
setDefaultUser: (uuid: string | null) => Promise<null>,
openMsaLogin: () => Promise<MinecraftCredentials | null> } };
<<<<<<< HEAD
<<<<<<< Updated upstream
const ARGS_MAP = { '': '{"send_event":["event"]}', 'onelauncher': '{"return_error":[],"open_dev_tools":[]}', 'core': '{"get_user":["uuid"],"get_default_user":["fallback"],"set_default_user":["uuid"],"get_global_profile":[],"get_clusters":[],"remove_cluster":["id"],"get_profile_or_default":["name"],"get_users":[],"remove_user":["uuid"],"open_msa_login":[],"create_cluster":["options"],"get_cluster":["id"]}' };
=======
const ARGS_MAP = { 'core': '{"getGlobalProfile":[],"getUsers":[],"removeUser":["uuid"],"getDefaultUser":["fallback"],"getUser":["uuid"],"getClusterById":["id"],"setDefaultUser":["uuid"],"openMsaLogin":[],"removeCluster":["id"],"getProfileOrDefault":["name"],"getClusters":[],"createCluster":["options"]}', 'onelauncher': '{"open_dev_tools":[],"return_error":[]}', '': '{"send_event":["event"]}' };
>>>>>>> 17734ca1b9033a35ee6a581cc55e25fa7fc932b3
export interface Router {
'onelauncher': { return_error: () => Promise<null>; open_dev_tools: () => Promise<void> };
'': { send_event: (event: LauncherEvent) => Promise<void> };
'core': { getClusters: () => Promise<Array<Model>>; getClusterById: (id: bigint) => Promise<Model | null>; removeCluster: (id: bigint) => Promise<null>; createCluster: (options: CreateCluster) => Promise<Model>; getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>; getGlobalProfile: () => Promise<SettingProfileModel>; getUsers: () => Promise<Array<MinecraftCredentials>>; getUser: (uuid: string) => Promise<MinecraftCredentials | null>; removeUser: (uuid: string) => Promise<null>; getDefaultUser: (fallback: boolean | null) => Promise<MinecraftCredentials | null>; setDefaultUser: (uuid: string | null) => Promise<null>; openMsaLogin: () => Promise<MinecraftCredentials | null> };
}
=======
>>>>>>> Stashed changes
export type { InferCommandOutput }
export const createTauRPCProxy = () => createProxy<Router>(ARGS_MAP)

View file

@ -124,8 +124,6 @@ function Banner({
// props.setNewCover(selected);
// }
const { setNewName, editMode, cluster } = props;
function updateName(name: string) {
if (name.length > 30 || name.length <= 0)
return;

View file

@ -13,7 +13,7 @@ function RouteComponent() {
const { id } = Route.useSearch();
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
const result = useCommand('getProfileOrDefault', () => bindings.core.getProfileOrDefault(cluster.data?.name as string), {
const _result = useCommand('getProfileOrDefault', () => bindings.core.getProfileOrDefault(cluster.data?.name as string), {
enabled: !!cluster.data?.name,
});

View file

@ -1,13 +1,12 @@
import type { Model } from '@/bindings.gen';
import DefaultBanner from '@/assets/images/default_banner.png';
import HeaderImage from '@/assets/images/default_banner.png';
import DefaultInstancePhoto from '@/assets/images/default_instance_cover.jpg';
import Modal from '@/components/overlay/Modal';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Button, TextField } from '@onelauncher/common/components';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Server01Icon } from '@untitled-theme/icons-react';
import { PlayIcon, Server01Icon } from '@untitled-theme/icons-react';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
export const Route = createFileRoute('/app/')({
@ -17,6 +16,8 @@ export const Route = createFileRoute('/app/')({
/*
Please note this route has a very big issue related to scrolling
and i am very angry rn so i will not be fixing it rn
hey future sassan here i guess the issue is solved idk
*/
function RouteComponent() {
const result = useCommand('getClusters', bindings.core.getClusters);
@ -111,11 +112,11 @@ function Banner() {
function ClusterCreate() {
const result = useCommand('createCluster', () => bindings.core.createCluster({
icon: 'asd',
icon: null,
mc_loader: 'vanilla',
mc_version: '1.20.1',
name: 'Test Cluster',
mc_loader_version: '0.13.5',
mc_loader_version: null,
}), {
enabled: false,
subscribed: false,
@ -125,7 +126,7 @@ function ClusterCreate() {
result.refetch();
if (result.isError)
alert(result.error.message);
console.error(result.error.message);
};
return (
@ -140,7 +141,7 @@ function ClusterCreate() {
<div className="min-w-sm flex flex-col rounded-lg bg-page text-center">
<div className="theme-OneLauncher-Dark relative h-25 flex">
<div className="absolute left-0 top-0 h-full w-full">
<img alt="Header Image" className="h-full w-full rounded-t-lg" src={HeaderImage} />
<img alt="Header Image" className="h-full w-full rounded-t-lg" src={DefaultBanner} />
</div>
<div
className="absolute left-0 top-0 h-full flex flex-row items-center justify-start gap-x-4 bg-[radial-gradient(at_center,#00000077,transparent)] px-10"
@ -209,6 +210,18 @@ function ClusterCard({
mc_loader,
mc_version,
}: Model) {
const launch = useCommand('launchCluster', () => bindings.core.launchCluster(id, null), {
enabled: false,
subscribed: false,
});
const handleLaunch = () => {
launch.refetch();
if (launch.error)
console.error(launch.error.message);
};
return (
<Link
search={{
@ -241,6 +254,7 @@ function ClusterCard({
</div>
{/* <LaunchButton cluster={props} iconOnly /> */}
<Button onClick={handleLaunch} size="icon"><PlayIcon /></Button>
</div>
</div>
</Link>

View file

@ -12,7 +12,7 @@ export const Route = createFileRoute('/app/settings/')({
});
function RouteComponent() {
const result = useCommand('getGlobalProfile', bindings.core.getGlobalProfile);
const _result = useCommand('getGlobalProfile', bindings.core.getGlobalProfile);
return (
<Sidebar.Page>

View file

@ -1,23 +1,15 @@
import Illustration from '@/assets/illustrations/onboarding/language.svg';
import { createFileRoute } from '@tanstack/react-router';
import { OnboardingStep } from './route';
import { languageList, OnboardingStep } from './route';
export const Route = createFileRoute('/onboarding/language')({
component: RouteComponent,
});
const languages = [
{
lang: 'English',
percentage: 100,
},
];
function RouteComponent() {
return (
<>
<OnboardingStep
// importing and using it as a react component was not working
illustration={<img src={Illustration} />}
paragraph="Choose your preferred language."
title="Language"
@ -27,7 +19,7 @@ function RouteComponent() {
<div className="max-h-84 overflow-hidden">
<div className="flex flex-col gap-y-1 p-2">
<aside>
{languages.map(x => (
{languageList.map(x => (
<div
className="flex flex-row items-center rounded-lg px-6 py-5"
key={x.lang}

View file

@ -5,12 +5,14 @@ import { useCommand } from '@onelauncher/common';
import { Button, Show } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { useState } from 'react';
import { useOnboardingContext } from './route';
export const Route = createFileRoute('/onboarding/login')({
component: RouteComponent,
});
function RouteComponent() {
const ctx = useOnboardingContext();
const [profile, setProfile] = useState<MinecraftCredentials>();
const [errorMessage, setErrorMessage] = useState<string>('');
const result = useCommand('openMsaLogin', bindings.core.openMsaLogin, {
@ -19,19 +21,21 @@ function RouteComponent() {
});
function beginMsAuthFlow() {
result.refetch();
result.refetch().then((data) => {
if (data.isError) {
setErrorMessage(data.error.message);
return;
}
if (result.isError) {
setErrorMessage(result.error.message);
return;
}
if (!data.data) {
setErrorMessage('No account was found. Please try again.');
return;
}
if (!result.data) {
setErrorMessage('No account was found. Please try again.');
return;
}
setProfile(data.data);
setProfile(result.data);
ctx.setIsForwardButtonEnabled(false);
});
}
return (
@ -51,12 +55,10 @@ function RouteComponent() {
</div>
</div>
</Show>
<Button
children="Login with Microsoft"
onPress={beginMsAuthFlow}
/>
<p className="text-danger">{errorMessage}</p>
</div>

View file

@ -1,108 +1,277 @@
import type { JSX } from 'react';
/* eslint-disable no-console -- we dont have a seperate logger for now */
import type { ReactNode } from 'react';
import { pluralize } from '@/utils';
import { Button } from '@onelauncher/common/components';
import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
export const Route = createFileRoute('/onboarding')({
component: RouteComponent,
});
const steps = [
'/onboarding', // done
'/onboarding/language', // done (only ui)
'/onboarding/login', // wip
'/onboarding/import', // wip
'/onboarding/summary', // wip
'/onboarding/complete', // wip
'/onboarding',
'/onboarding/language',
'/onboarding/login',
'/onboarding/import',
'/onboarding/summary',
'/onboarding/complete',
] as const;
interface Language {
lang: string;
code: string;
percentage: number;
}
export const languageList: Array<Language> = [
{
lang: 'English',
code: 'en',
percentage: 100,
},
];
enum OnboardingTaskStage {
NotStarted = 0,
Running = 1,
Completed = 2,
}
interface ImportInstancesType {
basePath: string;
instances: Array<string>;
}
interface _OnboardingContextType {
interface OnboardingContextType {
setLanguage: (language: Language) => void;
language: () => Language;
language: Language | undefined;
setImportInstances: (type: string, basePath: string, instances: Array<string>) => void;
importInstances: (type: string) => ImportInstancesType | undefined;
getImportInstance: (type: string) => ImportInstancesType | undefined;
setForwardButtonEnabled: (enabled: boolean) => void;
setIsForwardButtonEnabled: (enabled: boolean) => void;
getTasks: () => Array<string>;
tasksStage: OnboardingTaskStage;
tasksMessage: string;
}
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
export function useOnboardingContext() {
const context = useContext(OnboardingContext);
if (context === undefined)
throw new Error('useOnboardingContext must be used within an OnboardingProvider (RouteComponent)');
return context;
}
function RouteComponent() {
const navigate = useNavigate();
const routerState = useRouterState();
const navigate = useNavigate({ from: Route.fullPath });
const pathname = useRouterState({ select: s => s.location.pathname });
const currentPath = routerState.location.pathname;
const currentStepIndex = steps.findIndex(path => currentPath === path || currentPath.startsWith(path));
const currentStepIndex = useMemo(() => {
return steps.findIndex(stepPath => stepPath === pathname);
}, [pathname]);
const progressPercentage = currentStepIndex >= 0
? (currentStepIndex / (steps.length - 1)) * 100
: 0;
const [isBackButtonEnabled, setIsBackButtonEnabled] = useState(false);
const [isForwardButtonEnabled, setIsForwardButtonEnabled] = useState(true);
const handleBack = () => {
const [language, setLanguage] = useState<Language>();
const [importInstancesMap, setImportInstancesMap] = useState<Map<string, ImportInstancesType>>(new Map());
const [tasksStage, setTasksStage] = useState<OnboardingTaskStage>(OnboardingTaskStage.NotStarted);
const [tasksMessage, setTasksMessage] = useState<string>('');
const tasksCompleted = tasksStage === OnboardingTaskStage.Completed;
const percentage = (currentStepIndex / (steps.length - 1)) * 100;
const shallRunTasks = !tasksCompleted && currentStepIndex === steps.length - 2;
useEffect(() => {
const isSummaryStep = currentStepIndex === steps.length - 2;
const isCompleteStep = currentStepIndex === steps.length - 1;
setIsBackButtonEnabled(currentStepIndex > 0 && tasksStage !== OnboardingTaskStage.Running);
if (tasksStage === OnboardingTaskStage.Running)
setIsForwardButtonEnabled(false);
else if (tasksCompleted)
setIsForwardButtonEnabled(isSummaryStep || isCompleteStep);
else
setIsForwardButtonEnabled(true);
}, [currentStepIndex, tasksStage, tasksCompleted]);
const next = useCallback(async () => {
const isLast = currentStepIndex === steps.length - 1;
if (isLast) {
navigate({ to: '/app' });
}
else {
const nextPath = steps[currentStepIndex + 1];
navigate({ to: nextPath });
}
}, [currentStepIndex, navigate]);
const previous = useCallback(() => {
if (currentStepIndex > 0) {
const previousStep = steps[currentStepIndex - 1];
navigate({ to: previousStep as any });
}
};
const prevPath = steps[currentStepIndex - 1];
const handleNext = () => {
if (currentStepIndex === -1) {
navigate({ to: steps[0] as any });
return;
navigate({ to: prevPath });
}
}, [currentStepIndex, navigate]);
const getButtonText = useMemo(() => {
if (shallRunTasks)
return 'Setup';
if (currentStepIndex === steps.length - 1)
return 'Finish';
return 'Next';
}, [shallRunTasks, currentStepIndex]);
const runTasks = useCallback(async () => {
setIsBackButtonEnabled(false);
setIsForwardButtonEnabled(false);
setTasksStage(OnboardingTaskStage.Running);
setTasksMessage('Starting setup...');
const tasksToRun = [
async () => {
setTasksMessage(`Setting language to ${language?.lang}...`);
console.log('Language task completed for:', language?.lang);
},
async () => {
if (importInstancesMap.size === 0) {
console.log('No instances selected for import.');
return;
}
for (const [launcher, importData] of importInstancesMap.entries()) {
if (importData.instances.length === 0)
continue;
setTasksMessage(`Importing ${importData.instances.length} ${pluralize(importData.instances.length, 'instance')} from ${launcher}...`);
try {
console.log(`Simulating import for ${launcher}:`, importData);
}
catch (e) {
console.error(`Failed to import from ${launcher}:`, e);
setTasksMessage(`Failed to import ${pluralize(importData.instances.length, 'instance')} from ${launcher}...`);
throw e;
}
}
},
];
const results = await Promise.allSettled(tasksToRun.map(task => task()));
const errorCount = results.filter(({ status }) => status === 'rejected').length;
if (errorCount > 0) {
console.error('Onboarding tasks completed with error count:', errorCount);
setTasksMessage(`Setup completed with ${errorCount} ${pluralize(errorCount, 'error')}. Check console for details.`);
}
else {
setTasksMessage('Setup completed successfully!');
}
if (currentStepIndex < steps.length - 1) {
const nextStep = steps[currentStepIndex + 1];
navigate({ to: nextStep as any });
}
else if (currentStepIndex === steps.length - 1) {
navigate({ to: '/app' as any });
}
};
setTasksStage(OnboardingTaskStage.Completed);
// setIsBackButtonEnabled(true);
setIsForwardButtonEnabled(true);
next();
}, [importInstancesMap, next, language?.lang, setIsBackButtonEnabled, setIsForwardButtonEnabled, setTasksStage, setTasksMessage]);
const getTasks = useCallback((): Array<string> => {
const tasksStrings: Array<string> = [];
const selectedLangInfo = languageList.find(l => l.lang === language?.lang);
const langDisplayName = selectedLangInfo
? `${selectedLangInfo.lang} (Coverage: ${selectedLangInfo.percentage}%)`
: language?.lang;
tasksStrings.push(`Set language to ${langDisplayName}`);
importInstancesMap.forEach((importData, launcher) => {
if (importData.instances.length === 0)
return;
let builder = `Import ${importData.instances.length} ${pluralize(importData.instances.length, 'instance')} from ${launcher} (Source: ${importData.basePath}):\n`;
importData.instances.forEach((instance, index) => {
builder += ` ${index + 1}. ${instance}\n`;
});
tasksStrings.push(builder.trim());
});
return tasksStrings;
}, [language, importInstancesMap]);
const handleSetImportInstances = useCallback((type: string, newBasePath: string, instances: Array<string>) => {
setImportInstancesMap((prevMap) => {
const newMap = new Map(prevMap);
newMap.set(type, { basePath: newBasePath, instances });
return newMap;
});
}, []);
const getImportInstance = useCallback((type: string) => {
return importInstancesMap.get(type);
}, [importInstancesMap]);
const ctxValue: OnboardingContextType = useMemo(() => ({
setLanguage,
language,
setImportInstances: handleSetImportInstances,
getImportInstance,
setIsForwardButtonEnabled,
getTasks,
tasksStage,
tasksMessage,
}), [
language,
handleSetImportInstances,
getImportInstance,
setIsForwardButtonEnabled,
getTasks,
tasksStage,
tasksMessage,
]);
return (
// remind me 2 hours! i'll fix this
// update: it's fixed!
<div className="w-full flex flex-col items-center h-screen">
<div className="h-0.5 w-full">
<div
className="h-full rounded-lg bg-brand transition-all"
style={{
width: `${progressPercentage}%`,
}}
/>
</div>
<OnboardingContext value={ctxValue}>
<div className="w-full flex flex-col items-center h-screen bg-background-primary text-text-primary">
<div className="h-0.5 w-full">
<div
className="h-full rounded-lg bg-brand transition-all duration-300 ease-in-out"
style={{
width: `${percentage}%`,
}}
/>
</div>
<div className="flex-1 max-w-280 w-full flex flex-col gap-y-4 p-8">
<Outlet />
</div>
<div className="flex-1 max-w-5xl w-full flex flex-col gap-y-4 p-8 overflow-y-auto">
<Outlet />
</div>
<div className="w-full max-w-280 p-8">
<div className="w-1/3 flex flex-row items-stretch gap-x-8 [&>*]:w-full ml-auto">
<Button onClick={handleBack}>Back</Button>
<Button onClick={handleNext}>{currentStepIndex === steps.length - 1 ? 'Finish' : 'Next'}</Button>
<div className="w-full max-w-5xl p-8">
<div className="w-1/3 flex flex-row items-stretch gap-x-8 [&>*]:w-full ml-auto">
<Button color="secondary" isDisabled={!isBackButtonEnabled} onClick={previous}>Back</Button>
<Button color="primary" isDisabled={!isForwardButtonEnabled} onClick={shallRunTasks ? runTasks : next}>
{getButtonText}
</Button>
</div>
</div>
</div>
</div>
</OnboardingContext>
);
}
export interface OnboardingStepProps {
title: string;
paragraph: string;
illustration: JSX.Element;
children: JSX.Element;
illustration?: ReactNode;
children: ReactNode;
}
export function OnboardingStep({
@ -112,18 +281,18 @@ export function OnboardingStep({
children,
}: OnboardingStepProps) {
return (
<div className="grid grid-cols-2 h-full w-full gap-x-16">
<div className="flex flex-col items-center justify-center">
{illustration}
</div>
<div className={`grid ${illustration ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1'} h-full w-full gap-x-16`}>
{illustration && (
<div className="hidden md:flex flex-col items-center justify-center p-4">
{illustration}
</div>
)}
<div className="flex flex-col justify-center gap-y-4">
<div className="w-full flex flex-col gap-y-2">
<h1 className="text-2xl">{title}</h1>
<p className="text-lg text-fg-secondary line-height-normal">{paragraph}</p>
<h1 className="text-3xl font-semibold text-text-primary">{title}</h1>
<p className="text-lg text-text-secondary leading-relaxed">{paragraph}</p>
</div>
<div className="max-h-96 w-full flex flex-1 flex-col gap-y-2">
<div className="max-h-[30rem] w-full flex-1 flex flex-col gap-y-2 overflow-y-auto pr-2">
{children}
</div>
</div>

View file

@ -9,3 +9,9 @@ export const LAUNCHER_IMPORT_TYPES: Array<string> = [
// 'TLauncher',
// 'Technic'
] as const;
export function pluralize(n: number, word: string, locale: string = 'en'): string {
const pluralRules = new Intl.PluralRules(locale);
const pluralForm = pluralRules.select(n);
return pluralForm === 'one' ? word : `${word}s`;
}