Merge remote-tracking branch 'origin/onelauncher/main' into oneclient/main

This commit is contained in:
LynithDev 2025-07-22 11:05:50 +02:00
commit d4441ae0e4
22 changed files with 1432 additions and 462 deletions

View file

@ -42,6 +42,8 @@
"path": "$DATA/OneLauncher"
}
]
}
},
"opener:allow-open-url",
"opener:default"
]
}

View file

@ -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:",

View file

@ -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<T, TArray extends T>(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<SearchResult>; provider: Provider }) {
return (
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
<div className="grid grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-2">
{items.map(item => (
<PackageItem key={item.project_id} {...item} provider={provider} />
))}
@ -15,16 +22,14 @@ export function PackageGrid({ items, provider }: { items: Array<SearchResult>; 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 (
<div
<Link
className="h-full min-w-50 flex overflow-hidden rounded-lg bg-component-bg hover:bg-component-bg-hover flex-col max-h-74 min-h-74"
onClick={redirect}
params={{ provider, slug: item.slug }}
tabIndex={0}
to="/app/browser/package/$provider/$slug"
>
<div
className="relative flex items-center justify-center overflow-hidden w-full h-28"
@ -37,13 +42,28 @@ export function PackageItem({ provider, ...item }: SearchResult & { provider: Pr
)}
when={item.icon_url}
>
<img alt={`Icon for ${item.title}`} className="absolute z-0 max-w-none w-7/6 opacity-50 filter-blur-xl" src={item.icon_url} />
<img alt={`Icon for ${item.title}`} className="absolute z-0 max-w-none w-7/6 opacity-50 blur-xl" src={item.icon_url} />
<img
alt={`Icon for ${item.title}`}
className="relative z-1 aspect-ratio-square rounded-md image-render-auto w-2/5"
className="relative z-1 aspect-ratio-square rounded-lg image-render-auto h-5/6"
src={item.icon_url}
/>
</Show>
<Tooltip className="bg-component-bg-disabled" text={loaders.map(upperFirst).join(', ')}>
<Focusable>
<div className="flex flex-col rounded-full bg-component-bg/70 border-component-border/70 border p-1 absolute top-0 right-0 m-2">
{loaders.toSpliced(loaders.length > 3 ? 2 : 3).map(loader => <LoaderIcon className="w-5 m-1" key={loader} loader={loader} />)}
{loaders.length > 3 && (
<div className="bg-component-bg/50 rounded-full w-7 h-7 flex items-center justify-center">
<span className="tracking-tight -ml-0.5 mt-0.5">
+
{loaders.length - 2}
</span>
</div>
)}
</div>
</Focusable>
</Tooltip>
</div>
<div className="flex flex-1 flex-col gap-2 p-3">
<div className="flex flex-col gap-2">
@ -70,6 +90,6 @@ export function PackageItem({ provider, ...item }: SearchResult & { provider: Pr
</Show>
</div>
</div>
</div>
</Link>
);
}

View file

@ -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<ClusterFormData>({
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 = (
<ClusterLoader onSelectProvider={provider => setFormData(prev => ({ ...prev, clusterProvider: provider }))} selectedProvider={formData.clusterProvider} />
);
break;
const stepContent = useMemo((): JSX.Element => {
switch (currentStepConfig.id) {
case 'loader':
return (
<ClusterLoader
onSelectProvider={formHandlers.onProviderChange}
selectedProvider={formData.clusterProvider}
/>
);
case 'info':
return (
<ClusterInformation
clusterLoader={formData.loader}
clusterName={formData.clusterName}
clusterVersion={formData.clusterVersion}
onClusterLoaderChange={formHandlers.onLoaderChange}
onClusterNameChange={formHandlers.onNameChange}
onClusterVersionChange={formHandlers.onVersionChange}
/>
);
default:
return <div>Unknown step</div>;
}
case 'info': {
stepContent = (
<ClusterInformation
clusterLoader={formData.loader}
clusterName={formData.clusterName}
clusterVersion={formData.clusterVersion}
onClusterLoaderChange={loader => 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 (
<>
<div className="flex flex-row justify-between w-full">
<div>
<TextField className="p-1" iconLeft={<SearchMdIcon className="size-4" />} placeholder="Search for something..." />
</div>
<Modal.Trigger>
<Button>
<PlusIcon className="stroke-width-[2.2] size-5" />
New cluster
</Button>
<Modal>
<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={DefaultBanner} />
</div>
<div
className="absolute left-0 top-0 h-full flex w-full flex-row items-center justify-start gap-x-4 bg-[radial-gradient(at_center,#00000077,transparent)] px-10"
>
<Server01Icon className="h-8 w-8 text-fg-primary" />
<div className="flex flex-col items-start justify-center">
<h1 className="h-10 text-fg-primary text-2xl font-semibold">New Cluster</h1>
<span className="text-fg-primary">{STEPS[currentStepIndex].title}</span>
</div>
</div>
</div>
<div className="flex flex-col rounded-b-lg border border-white/5">
<div className="p-3">
{stepContent}
</div>
<div className="flex flex-row justify-end gap-x-2 p-3 pt-0">
<Button
children="Previous"
color="ghost"
isDisabled={isFirstStep}
onClick={handleBack}
/>
<Button
color="primary"
isDisabled={isNextDisabled}
onClick={handleNext}
>
{nextButtonText}
{' '}
<ArrowRightIcon />
</Button>
</div>
</div>
</div>
</Modal>
</Modal.Trigger>
<div className="flex flex-row justify-between w-full">
<div>
<TextField
aria-label="Cluster Search"
className="p-1"
iconLeft={<SearchMdIcon className="size-4" />}
placeholder="Search for something..."
/>
</div>
</>
<Button onPress={() => setIsModalOpen(true)}>
<PlusIcon className="stroke-width-[2.2] size-5" />
New cluster
</Button>
<Modal isDismissable isOpen={isModalOpen} onOpenChange={setIsModalOpen}>
<div className="min-w-sm flex flex-col rounded-lg bg-page text-center">
<ModalHeader currentStep={currentStepConfig} />
<div className="flex flex-col rounded-b-lg border border-white/5">
<div className="p-3">
{stepContent}
</div>
<ModalFooter
isFirstStep={isFirstStep}
isNextDisabled={isNextDisabled}
nextButtonText={nextButtonText}
onBack={handleBack}
onNext={handleNext}
/>
</div>
</div>
</Modal>
</div>
);
}
interface ModalHeaderProps {
currentStep: typeof STEPS[number];
}
function ModalHeader({ currentStep }: ModalHeaderProps) {
return (
<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={DefaultBanner}
/>
</div>
<div className="absolute left-0 top-0 h-full flex w-full flex-row items-center justify-start gap-x-4 bg-[radial-gradient(at_center,#00000077,transparent)] px-10">
<Server01Icon className="h-8 w-8 text-fg-primary" />
<div className="flex flex-col items-start justify-center">
<h1 className="h-10 text-fg-primary text-2xl font-semibold">New Cluster</h1>
<span className="text-fg-primary">{currentStep.title}</span>
</div>
</div>
</div>
);
}
interface ModalFooterProps {
isFirstStep: boolean;
isNextDisabled: boolean;
nextButtonText: string;
onBack: () => void;
onNext: () => void;
}
function ModalFooter({
isFirstStep,
isNextDisabled,
nextButtonText,
onBack,
onNext,
}: ModalFooterProps) {
return (
<div className="flex flex-row justify-end gap-x-2 p-3 pt-0">
<Button
color="ghost"
isDisabled={isFirstStep}
onClick={onBack}
>
Previous
</Button>
<Button
color="primary"
isDisabled={isNextDisabled}
onClick={onNext}
>
{nextButtonText}
{' '}
<ArrowRightIcon />
</Button>
</div>
);
}
@ -183,49 +260,60 @@ interface ClusterLoaderProps {
}
function ClusterLoader({ selectedProvider, onSelectProvider }: ClusterLoaderProps) {
return (
<>
<div className="grid grid-cols-3 gap-2">
<ProviderCard
icon={<User03Icon />}
name="New"
selected={selectedProvider === 'new'}
setSelected={() => onSelectProvider('new')}
/>
const providers = useMemo(() => [
{ id: 'new', name: 'New', icon: <User03Icon /> },
...LAUNCHER_IMPORT_TYPES.map(data => ({
id: data,
name: data,
icon: <LauncherIcon launcher={data} />,
})),
], []);
{LAUNCHER_IMPORT_TYPES.map((data, i) => (
<ProviderCard
icon={<LauncherIcon launcher={data} />}
key={i}
name={data}
selected={selectedProvider === data}
setSelected={() => onSelectProvider(data)}
/>
))}
</div>
</>
return (
<div className="grid grid-cols-3 gap-2">
{providers.map(provider => (
<ProviderCard
icon={provider.icon}
key={provider.id}
name={provider.name}
onSelect={() => onSelectProvider(provider.id)}
selected={selectedProvider === provider.id}
/>
))}
</div>
);
}
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 (
<div
className={`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 ${selected ? 'bg-component-bg' : ''}`}
onClick={() => setSelected()}
className={cardClassName}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
role="button"
tabIndex={0}
>
<div className="h-8 w-8 flex items-center justify-center [&>svg]:(w-8 h-8!)">
{icon}
</div>
<span>{name}</span>
<span className="text-sm font-medium">{name}</span>
</div>
);
}
@ -253,6 +341,7 @@ function ClusterInformation({
<div className="flex flex-col gap-y-4">
<Option header="Name">
<TextField
aria-label="Cluster Name"
className="w-full"
iconLeft={<TextInputIcon className="size-4" />}
onChange={e => onClusterNameChange(e.target.value)}
@ -296,61 +385,62 @@ interface VersionReleaseFilters {
snapshot: boolean;
}
function VersionSelector(props: VersionSelectorProps) {
const { versions, selectedVersion, onChange } = props;
const [filteredVersions, setFilteredVersions] = useState<Array<Version>>([]);
const [filters, setFilters] = useState<VersionReleaseFilters>({
old_alpha: false,
old_beta: false,
release: true,
snapshot: false,
});
const DEFAULT_FILTERS: VersionReleaseFilters = {
old_alpha: false,
old_beta: false,
release: true,
snapshot: false,
};
const refresh = useCallback((filters: VersionReleaseFilters, versions: Array<Version>) => {
setFilteredVersions(() => {
if (Object.values(filters).every(value => value === false))
return versions;
function VersionSelector({ versions, selectedVersion, onChange }: VersionSelectorProps) {
const [filters, setFilters] = useState<VersionReleaseFilters>(DEFAULT_FILTERS);
return versions.filter(version => filters[version.type]);
});
}, []);
const filteredVersions = useMemo(() => {
if (!versions)
return [];
const toggleFilter = (name: keyof VersionReleaseFilters) => {
if (Object.values(filters).every(value => !value))
return versions;
return versions.filter(version => filters[version.type]);
}, [versions, filters]);
const toggleFilter = useCallback((name: keyof VersionReleaseFilters) => {
setFilters(prev => ({
...prev,
[name]: !prev[name],
}));
};
}, []);
useEffect(() => {
if (versions)
refresh(filters, versions);
}, [filters, versions, refresh]);
useEffect(() => {
if (versions)
setFilteredVersions(versions);
}, [versions]);
const filterEntries = useMemo(() =>
Object.entries(filters) as Array<[keyof VersionReleaseFilters, boolean]>, [filters]);
return (
<div className="flex flex-1 flex-row gap-2">
<SelectList className="min-h-40 min-w-3/5 max-h-40 border border-component-border bg-component-bg" onChange={onChange} value={selectedVersion}>
<SelectList
className="min-h-40 min-w-3/5 max-h-40 border border-component-border bg-component-bg"
onChange={onChange}
value={selectedVersion}
>
<ScrollableContainer>
{filteredVersions.map(version => (
<SelectList.Item key={version.id} value={version.id}>{version.id}</SelectList.Item>
<SelectList.Item key={version.id} value={version.id}>
{version.id}
</SelectList.Item>
))}
</ScrollableContainer>
</SelectList>
<div className="flex flex-1 flex-col gap-y-2 overflow-hidden border border-component-border bg-component-bg rounded-lg p-2">
{Object.keys(filters).map(filter => (
{filterEntries.map(([filterName, isSelected]) => (
<Checkbox
className="selected:bg-component-bg-pressed rounded-lg"
isSelected={filters[filter as VersionType]}
key={filter}
onChange={() => toggleFilter(filter as VersionType)}
aria-label={`Filter ${filterName.replace('_', ' ')}`}
className="selected:bg-component-bg-pressed rounded-lg p-1 text-sm"
isSelected={isSelected}
key={filterName}
onChange={() => toggleFilter(filterName)}
>
{filter}
<span className="capitalize">{filterName.replace('_', ' ')}</span>
</Checkbox>
))}
</div>
@ -363,11 +453,13 @@ type OptionProps = {
children?: JSX.Element | Array<JSX.Element>;
} & RefAttributes<HTMLDivElement>;
function Option(props: OptionProps) {
function Option({ header, children, ...divProps }: OptionProps) {
return (
<div {...props} className="flex flex-col gap-y-2 items-stretch">
<h3 className="text-left text-md text-fg-secondary font-semibold uppercase">{props.header}</h3>
{props.children}
<div {...divProps} className="flex flex-col gap-y-2 items-stretch">
<h3 className="text-left text-md text-fg-secondary font-semibold uppercase">
{header}
</h3>
{children}
</div>
);
}

View file

@ -1,19 +1,68 @@
import type { DialogProps } from 'react-aria-components';
import type { VariantProps } from 'tailwind-variants';
import {
Dialog as AriaDialog,
Modal as AriaModal,
DialogTrigger,
ModalOverlay,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { tv } from 'tailwind-variants';
interface ModalProps extends DialogProps { }
const modalVariants = tv({
slots: {
overlay: [
'fixed inset-0 z-50',
'flex items-center justify-center',
'bg-black/50 p-4',
'data-[entering]:animate-in data-[entering]:fade-in data-[entering]:duration-200',
'data-[exiting]:animate-out data-[exiting]:fade-out data-[exiting]:duration-150',
],
modal: [
'data-[entering]:animate-in data-[entering]:zoom-in-95 data-[entering]:duration-200',
'data-[exiting]:animate-out data-[exiting]:zoom-out-95 data-[exiting]:duration-150',
],
dialog: [
'outline-none',
],
},
});
export interface ModalProps extends DialogProps, VariantProps<typeof modalVariants> {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
isDismissable?: boolean;
overlayClassName?: string;
modalClassName?: string;
}
function Modal({
className,
isOpen,
onOpenChange,
isDismissable = true,
overlayClassName,
modalClassName,
...props
}: ModalProps) {
const { overlay, modal, dialog } = modalVariants();
const isControlled = isOpen !== undefined || onOpenChange !== undefined;
const overlayProps = {
className: twMerge(overlay(), overlayClassName),
isDismissable,
...(isControlled && { isOpen, onOpenChange }),
};
function Modal({ className, ...props }: ModalProps) {
return (
<ModalOverlay className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" isDismissable>
<AriaModal>
<AriaDialog className="" {...props} />
<ModalOverlay {...overlayProps}>
<AriaModal className={twMerge(modal(), modalClassName)}>
<AriaDialog
className={twMerge(dialog(), className)}
{...props}
/>
</AriaModal>
</ModalOverlay>
);

View file

@ -1,10 +1,11 @@
import type { ClusterModel, GameLoader, ManagedPackage, ManagedVersion, Paginated, Provider, SearchQuery, SearchResult } from '@/bindings.gen';
import type { BindingCommands } from '@/types/global';
import type { UndefinedInitialDataOptions } from '@tanstack/react-query';
import { bindings } from '@/main';
import { PROVIDERS } from '@/utils';
import { useCommand } from '@onelauncher/common';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
export interface BrowserControllerType {
cluster: ClusterModel | undefined;
@ -29,19 +30,24 @@ export function BrowserProvider(props: any) {
const { children } = props;
const [cluster, setCluster] = useState<ClusterModel | undefined>(undefined);
const [provider, setProvider] = useState<Provider>(PROVIDERS[0]);
const navigate = useNavigate()
const navigate = useNavigate();
const [query, setQuery] = useState<SearchQuery>({
filters: null,
filters: {
categories: null,
game_versions: null,
loaders: null,
package_types: ["mod"]
},
query: null,
limit: 18 as unknown as bigint,
offset: null,
sort: null,
});
useEffect(()=>{
if(query.filters?.categories || query.query)
navigate({to: "/app/browser/search"})
},[query])
useEffect(() => {
if (query.filters?.categories || query.query)
navigate({ to: '/app/browser/search' });
}, [query]);
const context = useMemo<BrowserControllerType>(() => ({
cluster,
@ -65,10 +71,10 @@ export function useBrowserSearch(provider: Provider, query: SearchQuery, options
return useCommand('searchPackages', () => bindings.core.searchPackages(provider, validFilters ? query : { ...query, filters: null }), options);
}
export function usePackageData(provider: Provider, slug: string, options?: Omit<UndefinedInitialDataOptions<ManagedPackage>, "queryKey" | "queryFn"> | undefined){
return useCommand("getPackage", () => bindings.core.getPackage(provider, slug), options)
export function usePackageData(provider: Provider, slug: string, options?: Omit<UndefinedInitialDataOptions<ManagedPackage>, 'queryKey' | 'queryFn'> | undefined, key: false | BindingCommands | (string & {}) = `getPackage.${provider}.${slug}`) {
return useCommand(key, () => bindings.core.getPackage(provider, slug), options);
}
export function usePackageVersions(provider: Provider, slug: string, {mc_versions, loaders, offset, limit, ...options}:{mc_versions?:Array<string>|null, loaders?:Array<GameLoader>|null, offset?:number, limit: number} & Omit<UndefinedInitialDataOptions<Paginated<ManagedVersion>>, "queryKey" | "queryFn">){
return useCommand("getPackageVersions", ()=>bindings.core.getPackageVersions(provider, slug, mc_versions??null, loaders??null, (offset??0) as unknown as bigint, limit as unknown as bigint), options)
export function usePackageVersions(provider: Provider, slug: string, { mc_versions, loaders, offset, limit, ...options }: { mc_versions?: Array<string> | null; loaders?: Array<GameLoader> | null; offset?: number; limit: number } & Omit<UndefinedInitialDataOptions<Paginated<ManagedVersion>>, 'queryKey' | 'queryFn'>, key: false | BindingCommands | (string & {}) = `getPackageVersions.${provider}.${slug}.${mc_versions?.join('')}.${loaders?.join('')}`) {
return useCommand(key, () => bindings.core.getPackageVersions(provider, slug, mc_versions ?? null, loaders ?? null, (offset ?? 0) as unknown as bigint, limit as unknown as bigint), options);
}

View file

@ -1,54 +0,0 @@
import type { ClusterModel } from '@/bindings.gen';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { useEffect, useState } from 'react';
export function useRecentCluster() {
const result = useCommand('getClusters', bindings.core.getClusters);
const [cluster, setCluster] = useState<ClusterModel | undefined>();
useEffect(() => {
if (!result.data)
return;
let mostRecentCluster: ClusterModel | undefined;
for (const c of result.data) {
if (!mostRecentCluster) {
mostRecentCluster = c;
continue;
}
const currentPlayed = mostRecentCluster.last_played;
const newPlayed = c.last_played;
if (typeof currentPlayed !== 'string' && typeof newPlayed === 'string') {
mostRecentCluster = c;
}
else if (typeof currentPlayed === 'string' && typeof newPlayed === 'string') {
const playedAt = new Date(currentPlayed);
const clusterPlayedAt = new Date(newPlayed);
if (clusterPlayedAt > playedAt)
mostRecentCluster = c;
}
}
setCluster(mostRecentCluster);
}, [result.data]);
return cluster;
}
export function useClusters() {
const result = useCommand('getClusters', bindings.core.getClusters);
const [clusters, setClusters] = useState<Array<ClusterModel> | undefined>();
useEffect(() => {
if (!result.data)
return;
setClusters(result.data);
}, [result.data]);
return clusters;
}

View file

@ -0,0 +1,164 @@
import type { ClusterModel } from '@/bindings.gen';
import type { ModalProps } from '@/components/overlay/Modal';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { useEffect, useState } from 'react';
import Modal from '@/components/overlay/Modal';
import { Button, Dropdown } from '@onelauncher/common/components';
import { Heading } from 'react-aria-components';
export function useRecentCluster() {
const result = useCommand('getClusters', bindings.core.getClusters);
const [cluster, setCluster] = useState<ClusterModel | undefined>();
useEffect(() => {
if (!result.data)
return;
let mostRecentCluster: ClusterModel | undefined;
for (const c of result.data) {
if (!mostRecentCluster) {
mostRecentCluster = c;
continue;
}
const currentPlayed = mostRecentCluster.last_played;
const newPlayed = c.last_played;
if (typeof currentPlayed !== 'string' && typeof newPlayed === 'string') {
mostRecentCluster = c;
}
else if (typeof currentPlayed === 'string' && typeof newPlayed === 'string') {
const playedAt = new Date(currentPlayed);
const clusterPlayedAt = new Date(newPlayed);
if (clusterPlayedAt > playedAt)
mostRecentCluster = c;
}
}
setCluster(mostRecentCluster);
}, [result.data]);
return cluster;
}
export function useClusters() {
const result = useCommand('getClusters', bindings.core.getClusters);
const [clusters, setClusters] = useState<Array<ClusterModel> | undefined>();
useEffect(() => {
if (!result.data)
return;
setClusters(result.data);
}, [result.data]);
return clusters;
}
type ChooseClusterModalProps = ModalProps & {
clusters?: ClusterModel[];
confirmText?: string;
selected?: (clusters: ClusterModel[]) => number;
onSelected?: (cluster: ClusterModel) => void;
};
export function ChooseClusterModal({clusters: clusterList, selected: defaultSelected, onSelected, confirmText, ...props}: ChooseClusterModalProps) {
const clusters = useClusters()
const [selected, setSelected] = useState(0);
useEffect(() => {
if (!clusters) return;
const newSelected = defaultSelected?.(clusters)
if (newSelected) setSelected(newSelected)
}, [clusters])
function chooseCluster() {
if (clusters !== undefined && selected !== undefined) {
const cluster = clusters[selected];
if (cluster !== undefined)
onSelected?.(cluster);
}
}
return (
<Modal {...props}>
<div className="min-w-sm flex flex-col rounded-lg bg-page text-center p-4 gap-2">
<Heading slot='title' className='text-xl font-semibold'>Select a Cluster</Heading>
<Dropdown
onSelectionChange={(index) => {
setSelected(index as number)
}}
selectedKey={selected}
>
{clusters?.map((cluster, index) => (
<Dropdown.Item id={index} key={cluster.id}>
{cluster.name}
</Dropdown.Item>
))}
</Dropdown>
<div className='flex gap-2'>
<Button
color="secondary"
children="Close"
slot="close"
/>
<Button
children={confirmText ?? "Save"}
slot="close"
onClick={chooseCluster}
/>
</div>
</div>
</Modal>
)
// return (
// <Modal
// {...props}
// buttons={[
// <Button
// buttonStyle="secondary"
// children="Close"
// onClick={props.hide}
// />,
// <Button
// children="Save"
// onClick={chooseCluster}
// />,
// ]}
// children={(
// <Switch>
// <Match when={clusters() !== undefined}>
// <Dropdown
// disabled={clusters()?.length === 0}
// onChange={setSelected}
// selected={selected}
// >
// <Switch>
// <Match when={(clusters()?.length || 0) > 0}>
// <For each={clusters()!}>
// {cluster => (
// <Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
// )}
// </For>
// </Match>
// <Match when>
// <Dropdown.Row>No clusters found</Dropdown.Row>
// </Match>
// </Switch>
// </Dropdown>
// </Match>
// <Match when>
// <div>Loading...</div>
// </Match>
// </Switch>
// )}
// title="Choose a cluster"
// />
// );
}

View file

@ -1,5 +1,5 @@
import { Button, Show } from '@onelauncher/common/components';
import { ChevronRightIcon, ChevronLeftIcon } from '@untitled-theme/icons-react';
import { ChevronLeftIcon, ChevronRightIcon } from '@untitled-theme/icons-react';
import { useState } from 'react';
export interface PaginationOptions {
@ -16,7 +16,7 @@ function usePagination(options: PaginationOptions) {
const next = () => setPage(page => page + 1);
const prev = () => setPage(page => page - 1);
const totalPages = Math.ceil(options.itemsCount / options.itemsPerPage);
const totalPages = Math.ceil(options.itemsCount / options.itemsPerPage);
const hasNext = page < totalPages;
const hasPrev = page > 1;
@ -24,12 +24,12 @@ function usePagination(options: PaginationOptions) {
const offset = (page - 1) * options.itemsPerPage;
// Exported
function reset(/*options: PaginationOptions*/) {
function reset(/* options: PaginationOptions */) {
setPage(1);
}
function getVisiblePageNumbers(page: number) {
const pages: number[] = [];
const pages: Array<number> = [];
const left = page - 1 > 1 ? page - 1 : null;
const right = page + 1 < totalPages ? page + 1 : null;
@ -59,7 +59,7 @@ function usePagination(options: PaginationOptions) {
setPage(props.page);
}}
>
<span className='text-trim'>{props.page}</span>
<span className="text-trim">{props.page}</span>
</Button>
);
}
@ -68,11 +68,11 @@ function usePagination(options: PaginationOptions) {
<div className="flex flex-row items-center justify-center gap-x-1">
<Button
size='icon'
color='secondary'
children={<ChevronLeftIcon />}
color="secondary"
isDisabled={page <= 1}
onClick={() => setPage(page => page - 1)}
onClick={prev}
size="icon"
/>
<PaginationBtn page={1} />
@ -81,7 +81,7 @@ function usePagination(options: PaginationOptions) {
<span>...</span>
</Show>
{getVisiblePageNumbers(page).map(page=><PaginationBtn page={page} />)}
{getVisiblePageNumbers(page).map(page => <PaginationBtn key={page} page={page} />)}
<Show when={page < totalPages - 2}>
<span>...</span>
@ -92,11 +92,11 @@ function usePagination(options: PaginationOptions) {
</Show>
<Button
size="icon"
color='secondary'
children={<ChevronRightIcon />}
color="secondary"
isDisabled={page >= totalPages}
onClick={() => setPage(page => page + 1)}
onClick={next}
size="icon"
/>
</div>
);

View file

@ -1,6 +1,6 @@
import { isDev, registerDevExperience, registerNativeExperience } from '@onelauncher/common';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRouter, RouterProvider } from '@tanstack/react-router';
import { createHashHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { createTauRPCProxy } from './bindings.gen';
@ -25,6 +25,8 @@ const queryClient = new QueryClient({
},
});
const hashHistory = createHashHistory();
// Tanstack Router
const router = createRouter({
routeTree,
@ -36,6 +38,7 @@ const router = createRouter({
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
defaultNotFoundComponent: () => <div>Not Found</div>,
history: hashHistory,
});
declare module '@tanstack/react-router' {

View file

@ -1,16 +1,16 @@
import type { Provider } from '@/bindings.gen';
import OneConfigLogo from '@/assets/logos/oneconfig.svg';
import { PackageGrid } from '@/components/content/PackageItem';
import { useBrowserContext, useBrowserSearch } from '@/hooks/useBrowser';
import { useBrowserSearch, usePackageData } from '@/hooks/useBrowser';
import { PROVIDERS } from '@/utils';
import { Button, Show } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { ChevronRightIcon } from '@untitled-theme/icons-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { BrowserLayout } from './route';
export const Route = createFileRoute('/app/browser/')({
component: RouteComponent
component: RouteComponent,
});
@ -26,17 +26,22 @@ function RouteComponent() {
}
function Featured() {
const context = useBrowserContext();
// const context = useBrowserContext();
const [provider, slug] = useMemo<[Provider, string]>(() => ['Modrinth', 'iris'], []);
const featuredPackage = usePackageData(provider, slug, {});
const navigate = useNavigate();
const featuredImageIndex = useMemo(() => featuredPackage.data?.gallery.findIndex(image => image.featured), [featuredPackage.data]);
const [selectedImage, setSelectedImage] = useState(featuredImageIndex ?? 0);
return (
<Show when={!context.query.query && !(context.query.filters?.categories && context.query.filters.categories.length > 0)}>
<Show when={featuredPackage.isSuccess}>
<div className="flex flex-col gap-y-1">
<h5 className="ml-2">Featured</h5>
<div className="w-full flex flex-row overflow-hidden rounded-lg bg-page-elevated">
<h5 className="ml-2 uppercase opacity-60">Featured</h5>
<div className="w-full flex flex-col lg:flex-row overflow-hidden rounded-lg bg-page-elevated">
<div className="w-full p-1">
<img alt="thumbnail" className="aspect-ratio-video h-full rounded-md object-cover object-center" src="https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp" />
<img alt="thumbnail" className="aspect-video h-full w-full rounded-md object-cover object-center" src={featuredPackage.data?.gallery[selectedImage].url} />
</div>
<div className="max-w-84 min-w-52 flex flex-col gap-y-1 p-4">
<h2>Sodium</h2>
<div className="max-w-64 min-w-52 flex flex-col gap-y-1 p-4">
<h2 className="text-[1.5rem] font-semibold">{featuredPackage.data?.name}</h2>
<Show when={false}>
<div className="w-fit flex flex-row items-center gap-x-1 rounded-lg bg-border/10 px-1.5 py-1 text-fg-primary transition hover:opacity-80">
@ -45,11 +50,24 @@ function Featured() {
</div>
</Show>
<p className="mt-1 flex-1 leading-normal">Peak mod</p>
<p className="my-1 flex-1 leading-normal">{featuredPackage.data?.short_desc}</p>
<div className="grid grid-cols-3 gap-2 h-full">
{featuredPackage.data?.gallery.map(
(image, index) => (
<img
className="aspect-video object-center object-cover rounded-md"
key={image.url}
onClick={() => setSelectedImage(index)}
src={image.url}
/>
),
)}
</div>
<div className="flex flex-row justify-end">
<Button color="ghost">
View Package
<Button color="ghost" onClick={() => navigate({ to: '/app/browser/package/$provider/$slug', params: { provider, slug } })}>
View
<ChevronRightIcon />
</Button>
</div>
@ -73,7 +91,7 @@ function Lists() {
function List({ provider }: { provider: Provider }) {
const search = useBrowserSearch(provider, {
filters: null,
limit: 18 as unknown as bigint,
limit: 24 as unknown as bigint,
offset: null,
query: null,
sort: null,
@ -81,7 +99,7 @@ function List({ provider }: { provider: Provider }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<h3 className="uppercase p-3 opacity-60">{provider}</h3>
<h5 className="uppercase m-2 opacity-60">{provider}</h5>
<div className={`relative overflow-hidden ${expanded ? '' : 'h-128'}`}>
{search.isSuccess
? <PackageGrid items={search.data.items} provider={provider} />

View file

@ -1,84 +1,137 @@
import type { ClusterModel, ManagedPackage, ManagedUser, ManagedVersion, Paginated, Provider } from '@/bindings.gen'
import { useBrowserContext, usePackageData, usePackageVersions } from '@/hooks/useBrowser'
import { useClusters } from '@/hooks/useCluster'
import { bindings } from '@/main'
import { abbreviateNumber, formatAsRelative, PROVIDERS } from '@/utils'
import { useCommand } from '@onelauncher/common'
import { Show, Tooltip, Button, Dropdown } from '@onelauncher/common/components'
import { createFileRoute, Link } from '@tanstack/react-router'
import { openUrl } from '@tauri-apps/plugin-opener'
import { Download01Icon, LinkExternal01Icon, File02Icon, CalendarIcon, ClockRewindIcon, ChevronDownIcon, ChevronUpIcon } from '@untitled-theme/icons-react'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { Collection, ListBox, ListBoxItem, Popover, Select } from 'react-aria-components'
import type { ClusterModel, ManagedPackage, ManagedUser, ManagedVersion, PackageDonationUrl, Provider } from '@/bindings.gen';
import type { HTMLProps } from 'react';
import Modal from '@/components/overlay/Modal';
import { useBrowserContext, usePackageData, usePackageVersions } from '@/hooks/useBrowser';
import { ChooseClusterModal, useClusters } from '@/hooks/useCluster';
import { bindings } from '@/main';
import { abbreviateNumber, formatAsRelative, PROVIDERS, upperFirst } from '@/utils';
import { useCommand } from '@onelauncher/common';
import { Button, Show, Tooltip } from '@onelauncher/common/components';
import { createFileRoute, Link } from '@tanstack/react-router';
import { openUrl } from '@tauri-apps/plugin-opener';
import { CalendarIcon, ChevronDownIcon, ChevronUpIcon, ClockRewindIcon, Download01Icon, File02Icon, LinkExternal01Icon } from '@untitled-theme/icons-react';
import { createContext, useEffect, useMemo, useRef, useState } from 'react';
import { Cell, Collection, Column, ListBox, ListBoxItem, Popover, Pressable, Row, Select, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs } from 'react-aria-components';
import Markdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import { twMerge } from 'tailwind-merge';
import usePagination from '@/hooks/usePagination';
export const Route = createFileRoute('/app/browser/package/$provider/$slug')({
component: RouteComponent,
})
});
function includes<T, C extends T>(list: { includes: (arg0: C) => boolean }, element: T): element is C {
return list.includes(element as unknown as C)
function includes<T, TArray extends T>(list: { includes: (arg0: TArray) => boolean }, element: T): element is TArray {
return list.includes(element as unknown as TArray);
}
type PackageContextType = {
pkg: ManagedPackage | undefined
versions: Paginated<ManagedVersion> | undefined
function CustomA({ href, children, includeIcon, className, ...rest }: { href: string; children: any, includeIcon?: boolean } & HTMLProps<HTMLAnchorElement>) {
return (
<a
className={twMerge("text-fg-primary underline", className)}
href={href}
onClick={(e) => {
e.preventDefault();
openUrl(href);
}}
{...rest}
>
{children}
{includeIcon && <LinkExternal01Icon className='inline w-4 ml-1' />}
</a>
);
}
interface PackageContextType {
pkg: ManagedPackage | undefined;
}
const PackageContext = createContext<PackageContextType>({
pkg: undefined,
versions: undefined
})
});
function RouteComponent() {
const { provider, slug } = Route.useParams()
if (!includes(PROVIDERS, provider)) throw new Error("Invalid provider");
const packageData = usePackageData(provider, slug)
const browserContext = useBrowserContext()
const { data: versions } = usePackageVersions(provider, slug, {
mc_versions: browserContext.cluster ? [browserContext.cluster.mc_version] : null,
loaders: browserContext.cluster ? [browserContext.cluster.mc_loader] : null,
limit: 50
})
const { provider, slug } = Route.useParams();
if (!includes(PROVIDERS, provider))
throw new Error('Invalid provider');
const packageData = usePackageData(provider, slug, {});
const browserContext = useBrowserContext();
return (
<PackageContext value={{ pkg: packageData.data, versions }}>
<PackageContext value={{ pkg: packageData.data }}>
<Show when={packageData.isSuccess && !packageData.isFetching}>
<div className="h-full flex flex-1 flex-row items-start gap-x-4">
<BrowserSidebar package={packageData.data!} />
<div className="min-h-full flex flex-1 flex-col items-start gap-y-4 pb-8">
<div className="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1">
<Link to='/app/browser/package/$provider/$slug' params={{ provider, slug }}>About</Link>
<Link to='/app/browser/package/$provider/$slug' params={{ provider, slug }}>Versions</Link>
<Tabs className="min-h-full flex flex-1 flex-col items-start gap-y-4 pb-8">
<TabList className="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1 w-min">
<Tab className="uppercase p-2.5 text-trim rounded-md selected:bg-component-bg-pressed disabled:hidden" id="about">About</Tab>
<Tab className="uppercase p-2.5 text-trim rounded-md selected:bg-component-bg-pressed disabled:hidden" id="versions">Versions</Tab>
<Tab className="uppercase p-2.5 text-trim rounded-md selected:bg-component-bg-pressed disabled:hidden" id="gallery" isDisabled={packageData.data?.gallery.length === 0}>Gallery</Tab>
</TabList>
<TabPanel className="prose prose-invert prose-sm max-w-none prose-code:before:content-none prose-code:after:content-none prose-code:bg-component-bg-disabled prose-code:rounded-sm prose-code:p-1! prose-code:text-trim prose-img:inline-block prose-a:inline-block" id="about">
<div className="h-full min-h-full flex-1 w-full rounded-lg bg-component-bg p-3">
<Markdown
components={{
a: ({ node, children, ...props }) => <CustomA children={children} href={props.href as string} />,
}}
rehypePlugins={[rehypeRaw]}
remarkPlugins={[remarkGfm]}
>
{packageData.data?.body}
</Markdown>
</div>
</TabPanel>
<TabPanel id="versions">
<Versions/>
</TabPanel>
<TabPanel className="flex flex-wrap gap-2 justify-center" id="gallery">
{packageData.data?.gallery.map(item => (
<Modal.Trigger key={item.url}>
<Pressable>
<div className="rounded-md overflow-hidden bg-component-bg-pressed outline-0 h-64 flex flex-col relative" aria-label='Loaders'>
<img className="h-full" src={item.thumbnail_url} />
{item.title && <div className="absolute w-full bottom-0 bg-component-bg-disabled/80 p-2">{item.title}</div>}
</div>
</Pressable>
<Modal className="w-full">
<img className="rounded-xl" src={item.url} />
</Modal>
</Modal.Trigger>
))}
</div>
</TabPanel>
</Tabs>
<div className="h-full min-h-full w-full flex-1">
</div>
</div>
</div>
</Show>
</PackageContext>
)
);
}
function getPackageUrl(pkg: ManagedPackage): string {
switch (pkg.provider) {
case 'Modrinth': return `https://modrinth.com/project/${pkg.slug}`
case 'Curseforge': return `https://www.curseforge.com/minecraft/${pkg.package_type}s/${pkg.slug}`
case 'SkyClient': return ``
}
}
function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
const { provider } = Route.useParams()
const { provider } = Route.useParams();
const createdAt = useMemo(() => pkg.created ? new Date(pkg.created) : null, []);
const updatedAt = useMemo(() => pkg.updated ? new Date(pkg.updated) : null, []);
const authors = useCommand("getUsersFromAuthor", () => bindings.core.getUsersFromAuthor(provider as Provider, pkg.author))
const authors = useCommand('getUsersFromAuthor', () => bindings.core.getUsersFromAuthor(provider as Provider, pkg.author));
return (
<div className="sticky top-0 z-1 max-w-60 min-w-54 flex flex-col gap-y-4">
<div className="min-h-72 flex flex-col overflow-hidden rounded-lg bg-component-bg">
<div className="z-1 max-w-60 min-w-54 flex flex-col gap-y-4 mb-6">
<div className="flex flex-col overflow-hidden rounded-lg bg-component-bg">
<div className="relative h-28 flex items-center justify-center overflow-hidden">
<img alt={`Icon for ${pkg.name}`} className="absolute z-0 max-w-none w-7/6 filter-blur-xl" src={pkg.icon_url || ''} />
<img alt={`Icon for ${pkg.name}`} className="absolute z-0 max-w-none w-7/6 blur-xl" src={pkg.icon_url || ''} />
<img alt={`Icon for ${pkg.name}`} className="relative z-1 aspect-ratio-square w-2/5 rounded-md image-render-auto" src={pkg.icon_url || ''} />
</div>
<div className="flex flex-1 flex-col gap-2 p-3">
@ -106,22 +159,30 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
</div>
</div>
<InstallButton {...pkg} />
<InstallButton />
{/* <div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
<div className="flex flex-col rounded-lg bg-component-bg p-3">
<h4 className="text-fg-primary font-bold">Links</h4>
<Link href={getPackageUrl(contentPackage)} includeIcon>
{contentPackage.provider}
{' '}
Page
</Link>
</div> */}
<div className='flex flex-col'>
<CustomA href={getPackageUrl(pkg)} includeIcon className='text-link hover:text-link-hover'>
{provider}
{' '}
Page
</CustomA>
{(Object.entries(pkg.links) as [keyof typeof pkg.links, typeof pkg.links[keyof typeof pkg.links]][]).filter(a => a[1]).map(link =>
typeof link[1] == "string"
? <CustomA href={link[1]} children={upperFirst(link[0])} className='text-link hover:text-link-hover' includeIcon />
: (link[1] as PackageDonationUrl[]).map(donationLink => <CustomA href={donationLink.url} className='text-link hover:text-link-hover' children={upperFirst(donationLink.id)} includeIcon />)
)}
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
<h4 className="text-fg-primary font-bold">Authors</h4>
{
authors.isSuccess
? authors.data.map(author => <Author author={author} />)
? authors.data.map(author => <Author author={author} key={author.id} />)
: <h3>Loading...</h3>
}
@ -133,7 +194,7 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
<div className="flex flex-row items-start gap-x-1">
<File02Icon className="h-3 min-w-3 w-3" />
License
<Link to={pkg.license?.url ?? "#"}>
<Link to={pkg.license?.url ?? '#'}>
{pkg.license?.name || pkg.license?.id || 'Unknown'}
</Link>
</div>
@ -168,103 +229,104 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {
);
}
function colorForType(type: string) {
switch (type.toLowerCase()) {
case 'release':
return 'bg-code-trace';
case 'snapshot':
return 'bg-code-debug';
case 'beta':
return 'bg-code-warn';
case 'alpha':
return 'bg-code-error';
default:
return 'bg-border/05';
}
}
function InstallButton({ ...pkg }: ManagedPackage) {
const triggerRef = useRef<HTMLDivElement>(null)
const { provider, slug } = Route.useParams()
const [open, setOpen] = useState(false)
const clusters = useClusters()
const browserContext = useBrowserContext()
const { versions } = useContext(PackageContext)
function InstallButton() {
const triggerRef = useRef<HTMLDivElement>(null);
const { provider, slug } = Route.useParams();
if(!includes(PROVIDERS, provider)) throw new Error("invalid provider")
const [open, setOpen] = useState(false);
const clusters = useClusters();
const browserContext = useBrowserContext();
const { data: versions } = usePackageVersions(provider, slug, {
mc_versions: browserContext.cluster ? [browserContext.cluster.mc_version] : [],
loaders: browserContext.cluster ? [browserContext.cluster.mc_loader] : [],
limit: 1
});
const version = useMemo(() => {
if (!versions || !browserContext.cluster) return undefined;
return versions.items.findLast(version =>
version.mc_versions.includes(browserContext.cluster!.mc_version)
&& version.loaders.includes(browserContext.cluster!.mc_loader)
)
}, [browserContext.cluster, versions])
if (!versions || !browserContext.cluster)
return undefined;
return versions.items[0]
}, [browserContext.cluster, versions]);
function download() {
if (!version || !browserContext.cluster || !includes(PROVIDERS, provider)) return false;
downloadPackage(browserContext.cluster, provider, version)
if (!version || !browserContext.cluster || !includes(PROVIDERS, provider))
return false;
downloadPackage(browserContext.cluster, provider, version);
}
return (
<div className="h-12 flex flex-row w-full relative">
<Button
color={version ? "primary" : "secondary"}
className="max-w-full flex-1 rounded-r-none!"
onClick={download}
className="max-w-full flex-1 rounded-r-none disabled:text-white/50 disabled:bg-blue-900"
color="primary"
isDisabled={!version}
onClick={download}
>
<Download01Icon />
<div className='w-full text-sm'>
<div className="w-full text-sm">
{
browserContext.cluster ?
version
? <span>Download latest to <br /><span className='text-md font-semibold'>{browserContext.cluster.name}</span></span>
browserContext.cluster
? version
? (
<span>
Download latest to
<br />
<span className="text-md font-semibold">{browserContext.cluster.name}</span>
</span>
)
: `No matching version found`
: "Select a Cluster"
: 'Select a Cluster'
}
</div>
</Button>
<Button className="w-8 h-full rounded-l-none border-l border-white/10" onClick={() => setOpen(true)}>
<Button className="w-8 h-full rounded-l-none border-l border-white/10 px-2" onClick={() => setOpen(true)}>
{open
? <ChevronUpIcon />
: <ChevronDownIcon />
}
: <ChevronDownIcon />}
</Button>
<Select onOpenChange={setOpen} isOpen={open}
aria-label='cluster'
onSelectionChange={e => {
const cluster = clusters?.find(cluster => cluster.id as unknown as number == e)
if (cluster) browserContext.setCluster(cluster)
}}>
<div className="w-full h-full absolute -z-10" ref={triggerRef}></div>
<Select
aria-label="cluster" isOpen={open}
onOpenChange={setOpen}
onSelectionChange={(e) => {
const cluster = clusters?.find(cluster => cluster.id as unknown as number === e);
if (cluster)
browserContext.setCluster(cluster);
}}
>
<div className="w-full h-full absolute -z-10" ref={triggerRef}></div>
<Popover triggerRef={triggerRef} className="mt-1 rounded-lg shadow-md bg-component-bg border border-component-border -translate-x-full" style={{ width: `${triggerRef.current?.clientWidth}px` }}>
<Popover className="mt-1 rounded-lg shadow-md bg-component-bg border border-component-border -translate-x-full" style={{ width: `${triggerRef.current?.clientWidth}px` }} triggerRef={triggerRef}>
<ListBox className="outline-none flex flex-col gap-0.5">
<Collection items={clusters}>
{item => <ListBoxItem id={item.id as unknown as number}
className="group/item flex flex-row items-center justify-between gap-2 rounded-lg p-2 w-full text-dropdown-item-text hover:bg-component-bg-hover data-[focused]:bg-component-bg-pressed">{item.name}</ListBoxItem>}
{item => (
<ListBoxItem
className="group/item flex flex-row items-center justify-between gap-2 rounded-lg p-2 w-full text-dropdown-item-text hover:bg-component-bg-hover data-[focused]:bg-component-bg-pressed"
id={item.id as unknown as number}
>
{item.name}
</ListBoxItem>
)}
</Collection>
</ListBox>
</Popover>
</Select>
</div>
)
);
}
function Author({ author }: { author: ManagedUser }) {
//TODO: onclick and fallback avatar url
// TODO: onclick and fallback avatar url
return (
<a
className="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
onClick={() => { if (author.url) openUrl(author.url) }}
onClick={() => {
if (author.url)
openUrl(author.url);
}}
>
<img alt={`${author.username}'s avatar`} className="h-8 min-h-8 min-w-8 w-8 rounded-[5px]" src={author.avatar_url || ""} />
<img alt={`${author.username}'s avatar`} className="h-8 min-h-8 min-w-8 w-8 rounded-[5px]" src={author.avatar_url || ''} />
<div className="flex flex-1 flex-col justify-center gap-y-1">
<span>{author.username}</span>
@ -279,9 +341,96 @@ function Author({ author }: { author: ManagedUser }) {
<LinkExternal01Icon className="h-4 w-4" />
</a>
);
}
function colorForType(type: string) {
switch (type.toLowerCase()) {
case 'release':
return 'bg-code-trace/30';
case 'snapshot':
return 'bg-code-debug/30';
case 'beta':
return 'bg-code-warn/30';
case 'alpha':
return 'bg-code-error/30';
default:
return 'bg-border/30';
}
}
function Versions(){
const {provider, slug} = Route.useParams()
if(!includes(PROVIDERS, provider)) throw new Error("invalid provider")
const [offset, setOffset] = useState(0)
const { data: versions } = usePackageVersions(provider, slug, {
limit: 20,
offset
});
const pagination = usePagination({
itemsCount: versions?.total as unknown as number,
itemsPerPage: versions?.limit as unknown as number
})
useEffect(()=>{
setOffset(pagination.offset)
},[pagination.offset])
useEffect(()=>{
pagination.reset()
},[versions?.total])
return <div className='flex flex-col'>
<div className='flex justify-between'>
<pagination.Navigation/>
</div>
<Table className="border-separate border-spacing-x-none border-spacing-y-1">
<TableHeader className="text-left">
<Column className="pr-2"/>
<Column className="pr-4" isRowHeader>Name</Column>
<Column className="pr-4">Game Versions</Column>
<Column className="pr-4">Loaders</Column>
<Column className="pr-4">Created</Column>
<Column className="pr-4">Downloads</Column>
<Column className="pr-4"/>
</TableHeader>
<TableBody className="">
{versions?.items.map(item=>
<VersionRow version={item}/>
)}
</TableBody>
</Table>
</div>
}
function VersionRow({version}:{version:ManagedVersion}){
const {provider} = Route.useParams()
if(!includes(PROVIDERS, provider)) throw new Error("invalid provider")
return (
<Row className="my-2 bg-page-elevated px-4 [&>td]:py-4">
<Cell className="p-4 my-2 bg-component-bg rounded-l-xl">
<Tooltip text={upperFirst(version.release_type)}>
<div className={`${colorForType(version.release_type)} h-8 w-8 flex items-center justify-center rounded-md`}>
<span className="font-bold">{version.release_type.charAt(0).toUpperCase()}</span>
</div>
</Tooltip>
</Cell>
<Cell className="p-4 px-2 bg-component-bg">{version.display_name}</Cell>
<Cell className="p-4 pl-0 bg-component-bg">{version.mc_versions.join(", ")}</Cell>
<Cell className="p-4 pl-0 bg-component-bg">{version.loaders.map(upperFirst).join(", ")}</Cell>
<Cell className="p-4 pl-0 bg-component-bg">{formatAsRelative(Date.parse(version.published))}</Cell>
<Cell className="p-4 pl-0 bg-component-bg">{version.downloads}</Cell>
<Cell className="p-4 pl-0 bg-component-bg rounded-r-xl">
<Modal.Trigger>
<Button
size='icon'
color='secondary'
children={<Download01Icon />}
/>
<ChooseClusterModal confirmText='Download' onSelected={cluster=>downloadPackage(cluster, provider, version, true)}/>
</Modal.Trigger>
</Cell>
</Row>
)
}
function downloadPackage(cluster: ClusterModel, provider: Provider, version: ManagedVersion, skipCompatibility = false) {
return bindings.core.downloadPackage(provider, version.project_id, version.version_id, cluster.id, skipCompatibility)
return bindings.core.downloadPackage(provider, version.project_id, version.version_id, cluster.id, skipCompatibility);
}

View file

@ -18,9 +18,9 @@ function RouteComponent() {
<BrowserProvider>
<div>
<AnimatedOutlet
enter={{animate: {opacity: 1}, initial: {opacity: 1}}}
exit={{animate: {opacity: 0}, initial: {opacity: 0}}}
from={Route.id}
enter={{ animate: { opacity: 1 }, initial: { opacity: 1 } }}
exit={{ animate: { opacity: 0 }, initial: { opacity: 0 } }}
from={Route.id}
/>
</div>
</BrowserProvider>
@ -30,14 +30,14 @@ function RouteComponent() {
export function BrowserLayout(props: any) {
return (
<div className="relative h-full flex flex-1 flex-col items-center gap-2">
<div className="h-full w-full max-w-screen-xl flex flex-1 flex-col items-center gap-y-2">
<div className="h-full w-full max-w-screen-2xl flex flex-1 flex-col items-center gap-y-2">
<div className="grid grid-cols-[220px_auto_220px] w-full gap-x-6">
<div />
<BrowserToolbar />
<div />
</div>
<div className="grid grid-cols-[220px_auto_220px] w-full max-w-screen-xl gap-x-6 pb-8">
<div className="grid grid-cols-[220px_auto_220px] w-full gap-x-6 pb-8">
<BrowserCategories />
<div className="h-full flex flex-col gap-y-4">
@ -107,7 +107,7 @@ const defaultFilters: Filters = {
function BrowserCategories() {
const context = useBrowserContext();
const categories = browserCategories.byPackageType('mod', context.provider);
const categories = browserCategories.byPackageType((context.query.filters?.package_types ?? ["mod"])[0], context.provider);
function switchCategory(category: string) {
const newCategories = context.query.filters?.categories?.includes(category)
@ -122,7 +122,7 @@ function BrowserCategories() {
<div className="flex flex-col gap-y-6">
<Show when>
<div className="flex flex-col gap-y-2">
<h6 className="my-1 uppercase opacity-60">Categories</h6>
<h5 className="my-1 uppercase opacity-60">Categories</h5>
{categories.map(category => (
<p
aria-selected={context.query.filters?.categories?.includes(category.id)}
@ -142,16 +142,15 @@ function BrowserCategories() {
function BrowserToolbar() {
const context = useBrowserContext();
const [query, setQuery] = useState(context.query.query ?? "")
const [query, setQuery] = useState(context.query.query ?? '');
return (
<div className="w-full flex flex-row justify-between bg-page">
<div className="flex flex-row gap-2" />
<div className="flex flex-row gap-2">
<TextField
value={query}
iconLeft={<SearchMdIcon />}
onChange={e=>setQuery(e.currentTarget.value)}
onChange={e => setQuery(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter')
return;
@ -159,6 +158,7 @@ function BrowserToolbar() {
context.setQuery({ ...context.query, query: e.currentTarget.value });
}}
placeholder="Search for content"
value={query}
/>
</div>
</div>

View file

@ -1,12 +1,12 @@
import type { Paginated, SearchResult } from '@/bindings.gen';
import type { PaginationOptions } from '@/hooks/usePagination';
import { PackageGrid } from '@/components/content/PackageItem';
import { useBrowserContext, useBrowserSearch } from '@/hooks/useBrowser';
import usePagination from '@/hooks/usePagination';
import { Show } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useMemo, useState } from 'react';
import { BrowserLayout } from './route';
import type { Paginated, SearchResult } from '@/bindings.gen';
import type { PaginationOptions } from '@/hooks/usePagination';
import usePagination from '@/hooks/usePagination';
export const Route = createFileRoute('/app/browser/search')({
component: RouteComponent,
@ -33,40 +33,39 @@ function Search() {
return (
<>
{
search.data
? <Results results={search.data}/>
: <h1>Loading</h1>
search.data
? <Results results={search.data} />
: <h1>Loading</h1>
}
</>
);
}
function Results({results}:{results:Paginated<SearchResult>}){
function Results({ results }: { results: Paginated<SearchResult> }) {
const context = useBrowserContext();
const [oldTotal, setOldTotal] = useState(results.total)
const [oldTotal, setOldTotal] = useState(results.total);
const pagination = usePagination({
itemsCount: results.total as unknown as number,
itemsPerPage: context.query.limit as unknown as number
})
useEffect(()=>{
context.setQuery({...context.query, offset: pagination.offset as unknown as bigint})
},[pagination.page])
useEffect(()=>{
if(oldTotal === results.total) return;
pagination.reset()
console.log("pagination reset", results.total)
setOldTotal(results.total)
},[results.total])
itemsPerPage: context.query.limit as unknown as number,
});
useEffect(() => {
context.setQuery({ ...context.query, offset: pagination.offset as unknown as bigint });
}, [pagination.page]);
useEffect(() => {
if (oldTotal === results.total)
return;
pagination.reset();
setOldTotal(results.total);
}, [results.total]);
return (
<div>
<div className='w-full flex justify-end my-2'>
<pagination.Navigation/>
<div className="w-full flex justify-end my-2">
<pagination.Navigation />
</div>
{results.items.length == 0
? <h2 className='text-center text-lg font-light italic opacity-60'>No results</h2>
: <PackageGrid items={results.items} provider={context.provider}/>
}
{results.items.length === 0
? <h4 className="text-center text-lg font-light opacity-60">No results</h4>
: <PackageGrid items={results.items} provider={context.provider} />}
</div>
)
);
}

View file

@ -1,3 +1,4 @@
import Modal from '@/components/overlay/Modal';
import ScrollableContainer from '@/components/ScrollableContainer';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
@ -6,6 +7,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { convertFileSrc } from '@tauri-apps/api/core';
import { dataDir, join } from '@tauri-apps/api/path';
import { useEffect, useState } from 'react';
import { Pressable } from 'react-aria-components';
import Sidebar from '../settings/route';
export const Route = createFileRoute('/app/cluster/screenshots')({
@ -60,15 +62,20 @@ function ScreenshotEntry({
return (
<>
<div
className="flex flex-col items-center gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover hover:opacity-80 cursor-pointer"
>
<img
alt={path}
className="aspect-video w-full rounded-lg"
src={imagePath}
/>
</div>
<Modal.Trigger>
<Pressable>
<div className="rounded-md overflow-hidden bg-component-bg-pressed outline-0 h-64 flex flex-col relative">
<img
alt={path}
className="aspect-video w-full rounded-lg"
src={imagePath}
/>
</div>
</Pressable>
<Modal className="w-full">
<img className="rounded-xl" src={imagePath} />
</Modal>
</Modal.Trigger>
</>
);
}

View file

@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "tailwindcss-react-aria-components";
@plugin "tailwindcss-animated";
@import "../../node_modules/@onelauncher/common/dist/theme.css";

View file

@ -1,3 +1,5 @@
import type { PackageType, Provider } from "@/bindings.gen";
export const BROWSER_VIEWS: Array<string> = ['grid', 'list'] as const;
export interface CategoryItem {
@ -6,7 +8,7 @@ export interface CategoryItem {
}
export const browserCategories = {
byPackageType(packageType: 'mod' | string & {}, provider: string): Array<CategoryItem> {
byPackageType(packageType: PackageType, provider: Provider): Array<CategoryItem> {
switch (packageType) {
case 'mod':
return this.mod(provider);

View file

@ -77,6 +77,7 @@ pub struct SearchResult {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedPackage {
pub id: String,
pub slug: String,
pub provider: Provider,
pub package_type: PackageType,
pub name: String,
@ -97,6 +98,7 @@ pub struct ManagedPackage {
pub links: PackageLinks,
pub status: PackageStatus,
pub downloads: usize,
pub gallery: Vec<PackageGallery>
}
#[onelauncher_macro::specta]
@ -205,6 +207,16 @@ pub struct PackageDonationUrl {
pub url: String,
}
#[onelauncher_macro::specta]
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageGallery {
pub url: String,
pub thumbnail_url: String,
pub title: Option<String>,
pub description: Option<String>,
pub featured: Option<bool>
}
/// https://api.modrinth.com/v2/tag/donation_platform
#[onelauncher_macro::specta]
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -1,6 +1,6 @@
use super::ProviderExt;
use crate::api::packages::data::{
ManagedPackage, ManagedUser, ManagedVersion, ManagedVersionDependency, ManagedVersionFile, PackageAuthor, PackageDependencyType, PackageDonationUrl, PackageLicense, PackageLinks, PackageReleaseType, PackageSide, PackageStatus, SearchQuery, SearchResult, DEFAULT_LIMIT
ManagedPackage, ManagedUser, ManagedVersion, ManagedVersionDependency, ManagedVersionFile, PackageAuthor, PackageDependencyType, PackageDonationUrl, PackageGallery, PackageLicense, PackageLinks, PackageReleaseType, PackageSide, PackageStatus, SearchQuery, SearchResult, DEFAULT_LIMIT
};
use crate::error::LauncherResult;
use crate::utils::http;
@ -346,6 +346,7 @@ struct ModrinthPackage {
pub icon_url: Option<String>,
#[serde(alias = "project_id")]
pub id: String,
pub slug: String,
pub team: String,
#[serde(default)]
pub organization: Option<String>,
@ -363,7 +364,7 @@ struct ModrinthPackage {
#[serde(default)]
pub license: Option<PackageLicense>,
// #[serde(default)]
// pub gallery: Vec<Gallery>,
pub gallery: Vec<ModrinthGallery>,
#[serde(default)]
pub status: PackageStatus,
}
@ -373,6 +374,7 @@ impl From<ModrinthPackage> for ManagedPackage {
Self {
provider: Provider::Modrinth,
id: value.id,
slug: value.slug,
package_type: value.project_type,
name: value.title,
short_desc: value.description,
@ -405,6 +407,7 @@ impl From<ModrinthPackage> for ManagedPackage {
},
status: value.status,
downloads: value.downloads,
gallery: value.gallery.into_iter().map(Into::into).collect()
}
}
}
@ -532,6 +535,29 @@ impl From<ModrinthUser> for ManagedUser {
}
}
#[derive(Deserialize)]
pub struct ModrinthGallery {
#[serde(rename = "raw_url")]
pub url: String,
#[serde(rename = "url")]
pub thumbnail_url: String,
pub title: Option<String>,
pub description: Option<String>,
pub featured: Option<bool>
}
impl From<ModrinthGallery> for PackageGallery {
fn from(value: ModrinthGallery) -> Self {
Self {
url: value.url,
thumbnail_url: value.thumbnail_url,
title: value.title,
description: value.description,
featured: value.featured
}
}
}
#[allow(dead_code)]
enum FacetOperation {
NotEq,

View file

@ -11,8 +11,8 @@ import { tv } from 'tailwind-variants';
export const tooltipVariants = tv({
base: [
'px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-lg',
'border border-gray-700 max-w-xs z-50',
'px-3 py-2 text-sm font-medium text-white bg-component-bg rounded-lg shadow-lg',
'border border-component-border max-w-xs z-50',
'data-[entering]:animate-in data-[entering]:fade-in data-[entering]:zoom-in-95',
'data-[exiting]:animate-out data-[exiting]:fade-out data-[exiting]:zoom-out-95',
],

View file

@ -109,3 +109,7 @@ body {
.os-hide-horizontal-scrollbar > .os-scrollbar-horizontal {
display: none;
}
code {
@apply rounded-md p-2 text-left;
}

496
pnpm-lock.yaml generated
View file

@ -396,6 +396,15 @@ importers:
react-dom:
specifier: 'catalog:'
version: 19.1.0(react@19.1.0)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.1.2)(react@19.1.0)
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
tailwind-merge:
specifier: 'catalog:'
version: 3.2.0
@ -418,6 +427,9 @@ importers:
specifier: 'catalog:'
version: 2.1.5(@types/react@19.1.2)(react@19.1.0)
devDependencies:
'@tailwindcss/typography':
specifier: ^0.5.16
version: 0.5.16(tailwindcss@4.1.10)
'@tanstack/eslint-config':
specifier: 'catalog:'
version: 0.1.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)
@ -2624,6 +2636,11 @@ packages:
resolution: {integrity: sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==}
engines: {node: '>= 10'}
'@tailwindcss/typography@0.5.16':
resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tailwindcss/vite@4.1.10':
resolution: {integrity: sha512-QWnD5HDY2IADv+vYR82lOhqOlS1jSCUUAmfem52cXAhRTKxpDh3ARX8TTXJTCCO7Rv7cD2Nlekabv02bwP3a2A==}
peerDependencies:
@ -2862,9 +2879,15 @@ packages:
'@types/eslint@9.6.1':
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -2894,6 +2917,9 @@ packages:
'@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@ -2944,6 +2970,9 @@ packages:
resolution: {integrity: sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@unrs/resolver-binding-darwin-arm64@1.7.2':
resolution: {integrity: sha512-vxtBno4xvowwNmO/ASL0Y45TpHqmNkAaDtz4Jqb+clmcVSSl8XCG/PNFFkGsXXXS6AMjP+ja/TtNCFFa1QwLRg==}
cpu: [arm64]
@ -3250,6 +3279,9 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@ -3337,9 +3369,18 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
@ -3382,6 +3423,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
commander@6.2.1:
resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
engines: {node: '>= 6'}
@ -3445,6 +3489,11 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
cssstyle@4.1.0:
resolution: {integrity: sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==}
engines: {node: '>=18'}
@ -3839,6 +3888,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@ -3864,6 +3916,9 @@ packages:
exsolve@1.0.5:
resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -4043,6 +4098,27 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.0:
resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
@ -4061,6 +4137,12 @@ packages:
resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==}
engines: {node: '>=8'}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@ -4116,6 +4198,12 @@ packages:
intl-messageformat@10.7.16:
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
@ -4134,6 +4222,9 @@ packages:
resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==}
engines: {node: '>= 0.4'}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -4146,6 +4237,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-html@2.0.0:
resolution: {integrity: sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==}
engines: {node: '>=8'}
@ -4365,8 +4459,11 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.castarray@4.4.0:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@ -4430,9 +4527,21 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
mdast-util-mdx-jsx@3.2.0:
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
mdast-util-to-hast@13.2.0:
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
mdast-util-to-markdown@2.1.2:
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
@ -4724,6 +4833,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-imports-exports@0.2.4:
resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==}
@ -4812,6 +4924,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss@8.5.3:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
@ -4844,6 +4960,12 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
proxy-compare@3.0.1:
resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==}
@ -4882,6 +5004,12 @@ packages:
peerDependencies:
react: '>=16.8 || ^17.0.0 || ^18.0.0'
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@ -4937,6 +5065,21 @@ packages:
resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==}
hasBin: true
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
@ -5078,6 +5221,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spdx-correct@3.2.0:
resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
@ -5129,6 +5275,9 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@ -5152,8 +5301,11 @@ packages:
strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
style-to-object@1.0.8:
resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==}
style-to-js@1.1.17:
resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==}
style-to-object@1.0.9:
resolution: {integrity: sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
@ -5280,6 +5432,12 @@ packages:
traverse@0.3.9:
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
@ -5369,9 +5527,15 @@ packages:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-is@6.0.0:
resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@ -5433,6 +5597,15 @@ packages:
react:
optional: true
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
vfile-message@4.0.2:
resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==}
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite-node@2.1.3:
resolution: {integrity: sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -5585,6 +5758,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@ -8171,6 +8347,14 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.10
'@tailwindcss/oxide-win32-x64-msvc': 4.1.10
'@tailwindcss/typography@0.5.16(tailwindcss@4.1.10)':
dependencies:
lodash.castarray: 4.4.0
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 4.1.10
'@tailwindcss/vite@4.1.10(vite@6.3.4(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.80.3)(tsx@4.19.4)(yaml@2.6.0))':
dependencies:
'@tailwindcss/node': 4.1.10
@ -8424,8 +8608,16 @@ snapshots:
'@types/estree': 1.0.7
'@types/json-schema': 7.0.15
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.7
'@types/estree@1.0.7': {}
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/json-schema@7.0.15': {}
'@types/mdast@4.0.4':
@ -8452,6 +8644,8 @@ snapshots:
'@types/semver@7.5.8': {}
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@typescript-eslint/eslint-plugin@8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)':
@ -8531,6 +8725,8 @@ snapshots:
'@typescript-eslint/types': 8.31.1
eslint-visitor-keys: 4.2.0
'@ungap/structured-clone@1.3.0': {}
'@unrs/resolver-binding-darwin-arm64@1.7.2':
optional: true
@ -8855,6 +9051,8 @@ snapshots:
babel-plugin-jsx-dom-expressions: 0.39.8(@babel/core@7.27.1)
optional: true
bail@2.0.2: {}
balanced-match@1.0.2: {}
bare-events@2.4.2:
@ -8940,8 +9138,14 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
character-entities@2.0.2: {}
character-reference-invalid@2.0.1: {}
check-error@2.1.1:
optional: true
@ -8985,6 +9189,8 @@ snapshots:
delayed-stream: 1.0.0
optional: true
comma-separated-tokens@2.0.3: {}
commander@6.2.1: {}
comment-parser@1.4.1: {}
@ -9043,6 +9249,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
cssesc@3.0.0: {}
cssstyle@4.1.0:
dependencies:
rrweb-cssom: 0.7.1
@ -9501,7 +9709,7 @@ snapshots:
is-html: 2.0.0
kebab-case: 1.0.2
known-css-properties: 0.30.0
style-to-object: 1.0.8
style-to-object: 1.0.9
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@ -9632,6 +9840,8 @@ snapshots:
estraverse@5.3.0: {}
estree-util-is-identifier-name@3.0.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
@ -9662,6 +9872,8 @@ snapshots:
exsolve@1.0.5: {}
extend@3.0.2: {}
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
@ -9836,6 +10048,79 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.1.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw@9.1.0:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
parse5: 7.1.2
unist-util-position: 5.0.0
unist-util-visit: 5.0.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.7
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.1
mdast-util-mdx-jsx: 3.2.0
mdast-util-mdxjs-esm: 2.0.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.17
unist-util-position: 5.0.0
vfile-message: 4.0.2
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.0:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 6.5.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
he@1.2.0: {}
hosted-git-info@2.8.9: {}
@ -9851,6 +10136,10 @@ snapshots:
html-tags@3.3.1:
optional: true
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.1
@ -9897,8 +10186,7 @@ snapshots:
inherits@2.0.4: {}
inline-style-parser@0.2.4:
optional: true
inline-style-parser@0.2.4: {}
intl-messageformat@10.7.16:
dependencies:
@ -9907,6 +10195,13 @@ snapshots:
'@formatjs/icu-messageformat-parser': 2.11.2
tslib: 2.8.1
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-arrayish@0.2.1: {}
is-binary-path@2.1.0:
@ -9925,6 +10220,8 @@ snapshots:
dependencies:
hasown: 2.0.2
is-decimal@2.0.1: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@ -9933,6 +10230,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@2.0.1: {}
is-html@2.0.0:
dependencies:
html-tags: 3.3.1
@ -10134,7 +10433,9 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.debounce@4.0.8: {}
lodash.castarray@4.4.0: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {}
@ -10259,11 +10560,62 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-jsx@3.2.0:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-stringify-position: 4.0.0
vfile-message: 4.0.2
transitivePeerDependencies:
- supports-color
mdast-util-mdxjs-esm@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
unist-util-is: 6.0.0
mdast-util-to-hast@13.2.0:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@ungap/structured-clone': 1.3.0
devlop: 1.1.0
micromark-util-sanitize-uri: 2.0.1
trim-lines: 3.0.1
unist-util-position: 5.0.0
unist-util-visit: 5.0.0
vfile: 6.0.3
mdast-util-to-markdown@2.1.2:
dependencies:
'@types/mdast': 4.0.4
@ -10653,6 +11005,16 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
character-entities-legacy: 3.0.0
character-reference-invalid: 2.0.1
decode-named-character-reference: 1.1.0
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-imports-exports@0.2.4:
dependencies:
parse-statements: 1.0.11
@ -10671,7 +11033,6 @@ snapshots:
parse5@7.1.2:
dependencies:
entities: 4.5.0
optional: true
pascal-case@3.1.2:
dependencies:
@ -10731,6 +11092,11 @@ snapshots:
pluralize@8.0.0: {}
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss@8.5.3:
dependencies:
nanoid: 3.3.11
@ -10753,6 +11119,10 @@ snapshots:
process@0.11.10: {}
property-information@6.5.0: {}
property-information@7.1.0: {}
proxy-compare@3.0.1: {}
punycode@2.3.1: {}
@ -10848,12 +11218,23 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
react-just-parallax@3.1.16(react@19.1.0):
react-markdown@10.1.0(@types/react@19.1.2)(react@19.1.0):
dependencies:
framesync: 6.1.0
lodash.debounce: 4.0.8
prefix: 1.0.0
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.1.2
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.0
react: 19.1.0
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.0.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-refresh@0.17.0: {}
@ -10946,6 +11327,46 @@ snapshots:
dependencies:
jsesc: 0.5.0
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.2
micromark-util-types: 2.0.2
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-rehype@11.1.2:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
mdast-util-to-hast: 13.2.0
unified: 11.0.5
vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
remove-accents@0.5.0: {}
require-from-string@2.0.2: {}
@ -11118,6 +11539,8 @@ snapshots:
source-map@0.6.1: {}
space-separated-tokens@2.0.2: {}
spdx-correct@3.2.0:
dependencies:
spdx-expression-parse: 3.0.1
@ -11179,6 +11602,11 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@ -11197,10 +11625,13 @@ snapshots:
strnum@1.0.5: {}
style-to-object@1.0.8:
style-to-js@1.1.17:
dependencies:
style-to-object: 1.0.9
style-to-object@1.0.9:
dependencies:
inline-style-parser: 0.2.4
optional: true
supports-color@7.2.0:
dependencies:
@ -11322,6 +11753,10 @@ snapshots:
traverse@0.3.9: {}
trim-lines@3.0.1: {}
trough@2.2.0: {}
ts-api-utils@2.1.0(typescript@5.8.3):
dependencies:
typescript: 5.8.3
@ -11394,10 +11829,24 @@ snapshots:
unicorn-magic@0.3.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
bail: 2.0.2
devlop: 1.1.0
extend: 3.0.2
is-plain-obj: 4.1.0
trough: 2.2.0
vfile: 6.0.3
unist-util-is@6.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-position@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
@ -11481,6 +11930,21 @@ snapshots:
'@types/react': 19.1.2
react: 19.1.0
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
vfile-message@4.0.2:
dependencies:
'@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0
vfile@6.0.3:
dependencies:
'@types/unist': 3.0.3
vfile-message: 4.0.2
vite-node@2.1.3(@types/node@22.16.5)(lightningcss@1.30.1)(sass@1.80.3):
dependencies:
cac: 6.7.14
@ -11643,6 +12107,8 @@ snapshots:
xml-name-validator: 5.0.0
optional: true
web-namespaces@2.0.1: {}
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: