diff --git a/apps/onelauncher/desktop/capabilities/default.json b/apps/onelauncher/desktop/capabilities/default.json index b4bfbae1..2c30d3fa 100644 --- a/apps/onelauncher/desktop/capabilities/default.json +++ b/apps/onelauncher/desktop/capabilities/default.json @@ -42,6 +42,8 @@ "path": "$DATA/OneLauncher" } ] - } + }, + "opener:allow-open-url", + "opener:default" ] } diff --git a/apps/onelauncher/frontend/package.json b/apps/onelauncher/frontend/package.json index 69e0a441..09f0ddd0 100644 --- a/apps/onelauncher/frontend/package.json +++ b/apps/onelauncher/frontend/package.json @@ -33,6 +33,9 @@ "react": "catalog:", "react-aria-components": "catalog:", "react-dom": "catalog:", + "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "catalog:", "tailwind-variants": "catalog:", "tailwindcss": "catalog:", @@ -42,6 +45,7 @@ "valtio": "catalog:" }, "devDependencies": { + "@tailwindcss/typography": "^0.5.16", "@tanstack/eslint-config": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/apps/onelauncher/frontend/src/components/content/PackageItem.tsx b/apps/onelauncher/frontend/src/components/content/PackageItem.tsx index 63e9830f..ef7c9acf 100644 --- a/apps/onelauncher/frontend/src/components/content/PackageItem.tsx +++ b/apps/onelauncher/frontend/src/components/content/PackageItem.tsx @@ -1,12 +1,19 @@ import type { Provider, SearchResult } from '@/bindings.gen'; -import { abbreviateNumber } from '@/utils'; -import { Show } from '@onelauncher/common/components'; -import { useNavigate } from '@tanstack/react-router'; +import { abbreviateNumber, LOADERS, upperFirst } from '@/utils'; +import { Show, Tooltip } from '@onelauncher/common/components'; +import { Link, useNavigate } from '@tanstack/react-router'; import { Download01Icon } from '@untitled-theme/icons-react'; +import { useMemo } from 'react'; +import { Focusable } from 'react-aria-components'; +import LoaderIcon from '../launcher/LoaderIcon'; + +function includes(list: { includes: (arg0: TArray) => boolean }, element: T): element is TArray { + return list.includes(element as unknown as TArray); +} export function PackageGrid({ items, provider }: { items: Array; provider: Provider }) { return ( -
+
{items.map(item => ( ))} @@ -15,16 +22,14 @@ export function PackageGrid({ items, provider }: { items: Array; p } export function PackageItem({ provider, ...item }: SearchResult & { provider: Provider }) { - const navigate = useNavigate() - function redirect() { - navigate({to: "/app/browser/package/$provider/$slug", params: {provider, slug: item.slug}}) - } + const loaders = useMemo(() => item.categories.filter(cat => includes(LOADERS, cat)), []); return ( -
- {`Icon + {`Icon {`Icon + + +
+ {loaders.toSpliced(loaders.length > 3 ? 2 : 3).map(loader => )} + {loaders.length > 3 && ( +
+ + + + {loaders.length - 2} + +
+ )} +
+
+
@@ -70,6 +90,6 @@ export function PackageItem({ provider, ...item }: SearchResult & { provider: Pr
-
+ ); } diff --git a/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx b/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx index 8d0a8e8e..29dbc97a 100644 --- a/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx +++ b/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx @@ -1,4 +1,4 @@ -import type { GameLoader, Version, VersionType } from '@/bindings.gen'; +import type { GameLoader, Version } from '@/bindings.gen'; import type { JSX, RefAttributes } from 'react'; import DefaultBanner from '@/assets/images/default_banner.png'; import LauncherIcon from '@/components/content/LauncherIcon'; @@ -10,17 +10,17 @@ import { useCommand, useCommandMut } from '@onelauncher/common'; import { Button, Dropdown, SelectList, TextField } from '@onelauncher/common/components'; import { useQueryClient } from '@tanstack/react-query'; import { ArrowRightIcon, PlusIcon, SearchMdIcon, Server01Icon, TextInputIcon, User03Icon } from '@untitled-theme/icons-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Checkbox } from 'react-aria-components'; import LoaderIcon from '../LoaderIcon'; const STEPS = [ { - id: 'loader', + id: 'loader' as const, title: 'Select Provider', }, { - id: 'info', + id: 'info' as const, title: 'Game Setup', }, ] as const; @@ -32,9 +32,16 @@ interface ClusterFormData { clusterProvider: string; } +interface FormValidation { + isProviderValid: boolean; + isNameValid: boolean; + isVersionValid: boolean; +} + export function NewClusterCreate() { const queryClient = useQueryClient(); const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isModalOpen, setIsModalOpen] = useState(false); const [formData, setFormData] = useState({ loader: 'vanilla', clusterName: '', @@ -42,6 +49,12 @@ export function NewClusterCreate() { clusterProvider: '', }); + const validation = useMemo((): FormValidation => ({ + isProviderValid: Boolean(formData.clusterProvider), + isNameValid: Boolean(formData.clusterName.trim()), + isVersionValid: Boolean(formData.clusterVersion.trim()), + }), [formData.clusterProvider, formData.clusterName, formData.clusterVersion]); + const create = useCommandMut(() => bindings.core.createCluster({ icon: null, mc_loader: formData.loader, @@ -51,129 +64,193 @@ export function NewClusterCreate() { }), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['getClusters'] }); + // Modal'ı kapat ve formu sıfırla + setIsModalOpen(false); + setCurrentStepIndex(0); + setFormData({ + loader: 'vanilla', + clusterName: '', + clusterVersion: '', + clusterProvider: '', + }); }, }); - function handleCreate() { - if (!isLastInputStep && !formData.clusterName) + const handleCreate = useCallback(() => { + if (!validation.isNameValid || !validation.isVersionValid) { + console.warn('Form validation failed'); return; + } create.mutate(); + }, [create, validation.isNameValid, validation.isVersionValid]); - if (create.isError) - console.error(create.error.message); - } - - const handleNext = useCallback(async () => { - if (currentStepIndex === STEPS.length - 1) + const handleNext = useCallback(() => { + if (currentStepIndex === STEPS.length - 1) { handleCreate(); + return; + } if (currentStepIndex < STEPS.length - 1) - setCurrentStepIndex(currentStepIndex + 1); - }, [currentStepIndex]); + setCurrentStepIndex(prev => prev + 1); + }, [currentStepIndex, handleCreate]); const handleBack = useCallback(() => { if (currentStepIndex > 0) - setCurrentStepIndex(currentStepIndex - 1); + setCurrentStepIndex(prev => prev - 1); }, [currentStepIndex]); + const formHandlers = useMemo(() => ({ + onProviderChange: (provider: string) => + setFormData(prev => ({ ...prev, clusterProvider: provider })), + onLoaderChange: (loader: GameLoader) => + setFormData(prev => ({ ...prev, loader })), + onNameChange: (name: string) => + setFormData(prev => ({ ...prev, clusterName: name })), + onVersionChange: (version: string) => + setFormData(prev => ({ ...prev, clusterVersion: version })), + }), []); + const currentStepConfig = STEPS[currentStepIndex]; - let stepContent: JSX.Element; - switch (currentStepConfig.id) { - case 'loader': { - stepContent = ( - setFormData(prev => ({ ...prev, clusterProvider: provider }))} selectedProvider={formData.clusterProvider} /> - ); - break; + const stepContent = useMemo((): JSX.Element => { + switch (currentStepConfig.id) { + case 'loader': + return ( + + ); + case 'info': + return ( + + ); + default: + return
Unknown step
; } - - case 'info': { - stepContent = ( - setFormData(prev => ({ ...prev, loader }))} - onClusterNameChange={name => setFormData(prev => ({ ...prev, clusterName: name }))} - onClusterVersionChange={version => - setFormData(prev => ({ ...prev, clusterVersion: version }))} - /> - ); - break; - } - } + }, [currentStepConfig.id, formData, formHandlers]); const isFirstStep = currentStepIndex === 0; const isLastInputStep = currentStepIndex === STEPS.length - 1; const nextButtonText = isLastInputStep ? 'Create' : 'Next'; - let isNextDisabled = false; - if (currentStepConfig.id === 'loader' && !formData.clusterProvider) - isNextDisabled = true; - else if ( - currentStepConfig.id === 'info' - && (!formData.clusterName.trim() || !formData.clusterVersion.trim()) - ) - isNextDisabled = false; + const isNextDisabled = useMemo(() => { + switch (currentStepConfig.id) { + case 'loader': + return !validation.isProviderValid; + case 'info': + return !validation.isNameValid || !validation.isVersionValid; + default: + return false; + } + }, [currentStepConfig.id, validation]); return ( - <> -
-
- } placeholder="Search for something..." /> -
- - - - - -
-
-
- Header Image -
-
- -
-

New Cluster

- {STEPS[currentStepIndex].title} -
-
-
-
-
- {stepContent} -
- -
- -
-
-
-
-
+
+
+ } + placeholder="Search for something..." + />
- + + + + +
+ + +
+
+ {stepContent} +
+ + +
+
+
+
+ ); +} + +interface ModalHeaderProps { + currentStep: typeof STEPS[number]; +} + +function ModalHeader({ currentStep }: ModalHeaderProps) { + return ( +
+
+ Header Image +
+
+ +
+

New Cluster

+ {currentStep.title} +
+
+
+ ); +} + +interface ModalFooterProps { + isFirstStep: boolean; + isNextDisabled: boolean; + nextButtonText: string; + onBack: () => void; + onNext: () => void; +} + +function ModalFooter({ + isFirstStep, + isNextDisabled, + nextButtonText, + onBack, + onNext, +}: ModalFooterProps) { + return ( +
+ + +
); } @@ -183,49 +260,60 @@ interface ClusterLoaderProps { } function ClusterLoader({ selectedProvider, onSelectProvider }: ClusterLoaderProps) { - return ( - <> -
- } - name="New" - selected={selectedProvider === 'new'} - setSelected={() => onSelectProvider('new')} - /> + const providers = useMemo(() => [ + { id: 'new', name: 'New', icon: }, + ...LAUNCHER_IMPORT_TYPES.map(data => ({ + id: data, + name: data, + icon: , + })), + ], []); - {LAUNCHER_IMPORT_TYPES.map((data, i) => ( - } - key={i} - name={data} - selected={selectedProvider === data} - setSelected={() => onSelectProvider(data)} - /> - ))} -
- + return ( +
+ {providers.map(provider => ( + onSelectProvider(provider.id)} + selected={selectedProvider === provider.id} + /> + ))} +
); } interface ProviderCardProps { - setSelected: () => void; + onSelect: () => void; selected: boolean; icon: JSX.Element; name: string; } -function ProviderCard(props: ProviderCardProps) { - const { selected, setSelected, icon, name } = props; +function ProviderCard({ selected, onSelect, icon, name }: ProviderCardProps) { + const cardClassName = useMemo(() => { + const baseClasses = 'flex flex-col justify-center items-center gap-y-3 py-2 px-4 hover:bg-component-bg-hover active:bg-component-bg-pressed rounded-lg cursor-pointer transition-colors'; + return selected ? `${baseClasses} bg-component-bg` : baseClasses; + }, [selected]); return (
setSelected()} + className={cardClassName} + onClick={onSelect} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(); + } + }} + role="button" + tabIndex={0} >
{icon}
- {name} + {name}
); } @@ -253,6 +341,7 @@ function ClusterInformation({