forked from OctoWoW/OctoLauncher
Initial commit
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, RefreshCw, Search } from 'lucide-react';
|
||||
|
||||
import { type AddonData, type AddonsStatus } from '~main/types';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import TextButton from '~renderer/components/styled/TextButton';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
|
||||
import DialogButton from '../styled/DialogButton';
|
||||
import IconSpinner from '../styled/IconSpinner';
|
||||
|
||||
import AddonList from './addons/AddonList';
|
||||
import { type Dependencies } from './addons/AddonListItem';
|
||||
import CustomAddonDialog from './addons/CustomAddonDialog';
|
||||
|
||||
const localeFilter = (l: AddonData[], filter: string) => {
|
||||
const seen = new Set<string>();
|
||||
const deduped = l.filter(a => {
|
||||
if (seen.has(a.folder)) return false;
|
||||
seen.add(a.folder);
|
||||
return true;
|
||||
});
|
||||
return deduped
|
||||
.filter(
|
||||
a =>
|
||||
a.folder.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1
|
||||
)
|
||||
.sort((a, b) => a.folder.localeCompare(b.folder));
|
||||
};
|
||||
|
||||
const AddonsTab = () => {
|
||||
const [data, setData] = useState<AddonsStatus>({
|
||||
state: 'verifying',
|
||||
addons: {},
|
||||
available: []
|
||||
});
|
||||
api.addons.observe.useSubscription(undefined, { onData: setData });
|
||||
|
||||
const isUpdateAvailable = Object.values(data.addons).some(
|
||||
a => a.status === 'outOfDate' || a.status === 'downloading'
|
||||
);
|
||||
const dependencies: Dependencies = Object.fromEntries([
|
||||
...data.available.map(a => [a.folder, 'available']),
|
||||
...Object.values(data.addons).map(a => [
|
||||
a.folder,
|
||||
a.progress ?? (a.status === 'upToDate' ? 'installed' : 'available')
|
||||
])
|
||||
]);
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const verify = api.addons.verify.useMutation();
|
||||
const update = api.addons.update.useMutation();
|
||||
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className="tw-surface relative flex min-h-0 flex-grow flex-col gap-3">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3"
|
||||
>
|
||||
<AddonList
|
||||
title="Installed"
|
||||
addons={localeFilter(Object.values(data.addons), filter)}
|
||||
dependencies={dependencies}
|
||||
/>
|
||||
<AddonList
|
||||
title="Available"
|
||||
addons={localeFilter(
|
||||
data.available.filter(a => !(a.folder in data.addons)),
|
||||
filter
|
||||
)}
|
||||
dependencies={dependencies}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="-mb-4 -mt-3 grid grid-cols-[1fr_1fr_1fr] items-center justify-between gap-2 py-2">
|
||||
<TextButton
|
||||
onClick={() => verify.mutateAsync()}
|
||||
className="-ml-2 text-blueGray"
|
||||
icon={RefreshCw}
|
||||
size={18}
|
||||
loading={data.state !== 'done'}
|
||||
>
|
||||
Check for updates
|
||||
</TextButton>
|
||||
<DialogButton
|
||||
clickAway
|
||||
dialog={close => <CustomAddonDialog close={close} />}
|
||||
>
|
||||
{open => (
|
||||
<TextButton
|
||||
icon={Plus}
|
||||
size={18}
|
||||
onClick={open}
|
||||
className="s1 text-pink"
|
||||
>
|
||||
Add custom git addon
|
||||
</TextButton>
|
||||
)}
|
||||
</DialogButton>
|
||||
{data.state === 'verifying' ? (
|
||||
<IconSpinner size={18} className="justify-self-end" />
|
||||
) : isUpdateAvailable ? (
|
||||
<TextButton
|
||||
onClick={() => update.mutateAsync({})}
|
||||
className="justify-self-end text-warmGreen"
|
||||
>
|
||||
Update all
|
||||
</TextButton>
|
||||
) : (
|
||||
<p className="s1 justify-self-end text-blueGray">
|
||||
Everything is up to date.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-3 top-3">
|
||||
<div className="flex items-center gap-1 border-b border-blueGray bg-darkGray/70 p-1 hocus:border-orange">
|
||||
<input
|
||||
className="cursor-text bg-inherit"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
<Search size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AddonsTab;
|
||||
@@ -0,0 +1,7 @@
|
||||
const ComingSoonTab = () => (
|
||||
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
|
||||
<p className="italic text-blueGray">Coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ComingSoonTab;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { api } from '~renderer/utils/api';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
import { type ModRowStatus, type ModsStatus } from '~main/types';
|
||||
|
||||
import TextButton from '../styled/TextButton';
|
||||
import CheckboxInput from '../form/CheckboxInput';
|
||||
import IconSpinner from '../styled/IconSpinner';
|
||||
|
||||
const RowState = ({ row }: { row: ModRowStatus }) => {
|
||||
if (row.state === 'downloading' || row.state === 'installing')
|
||||
return <IconSpinner className="text-blueGray" />;
|
||||
if (row.state === 'uninstalling')
|
||||
return <IconSpinner className="text-blueGray" />;
|
||||
if (row.state === 'error')
|
||||
return (
|
||||
<span title={row.error}>
|
||||
<AlertTriangle size={14} className="text-red" />
|
||||
</span>
|
||||
);
|
||||
if (row.installedVersion && row.installedVersion !== row.latestVersion && !row.ignoreUpdates)
|
||||
return <span className="s1 text-pink">update</span>;
|
||||
return null;
|
||||
};
|
||||
|
||||
const ModRow = ({ row }: { row: ModRowStatus }) => {
|
||||
const toggle = api.mods.toggle.useMutation();
|
||||
const setIgnore = api.mods.setIgnoreUpdates.useMutation();
|
||||
const openLink = api.general.openLink.useMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-baseline gap-2">
|
||||
{row.recommended && (
|
||||
<Sparkles size={12} className="shrink-0 text-warmGreen" />
|
||||
)}
|
||||
<span className={cls(row.recommended && 'text-warmGreen')}>{row.name}</span>
|
||||
<span className="s1 text-warmGreen">{row.latestVersion}</span>
|
||||
</div>
|
||||
<CheckboxInput
|
||||
value={row.enabled}
|
||||
setValue={v => toggle.mutate({ id: row.id, enabled: v })}
|
||||
className="justify-self-center"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="s1 text-blueGray">{row.description}</p>
|
||||
<TextButton
|
||||
icon={ExternalLink}
|
||||
size={14}
|
||||
title={row.repoUrl}
|
||||
onClick={() => openLink.mutateAsync(row.repoUrl)}
|
||||
className="!p-0 text-blueGray"
|
||||
/>
|
||||
<RowState row={row} />
|
||||
</div>
|
||||
<CheckboxInput
|
||||
value={row.ignoreUpdates}
|
||||
setValue={v => setIgnore.mutate({ id: row.id, ignore: v })}
|
||||
label={<span className="s1">Ignore updates</span>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ModsTab = () => {
|
||||
const [status, setStatus] = useState<ModsStatus>();
|
||||
api.mods.observe.useSubscription(undefined, {
|
||||
onData: setStatus
|
||||
});
|
||||
|
||||
const list = api.mods.list.useQuery(undefined, {
|
||||
refetchOnMount: true
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!status && list.data) setStatus(list.data);
|
||||
}, [list.data, status]);
|
||||
|
||||
const apply = api.mods.applyAll.useMutation();
|
||||
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h4 className="tw-color">CUSTOM MODS</h4>
|
||||
{status?.dirty && (
|
||||
<span className="s1 text-pink">unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="s1 text-blueGray">
|
||||
<span className="text-orange">⚠</span> Enabling custom mods may not provide
|
||||
any performance benefits or may even cause game crashes depending on your
|
||||
system. Please try disabling them if you experience any issues.
|
||||
</p>
|
||||
<hr />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="relative -m-4 -mt-0 grid flex-grow grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2 overflow-y-auto p-4 pt-0"
|
||||
>
|
||||
{status?.mods.map(row => <ModRow key={row.id} row={row} />)}
|
||||
</div>
|
||||
<hr />
|
||||
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
|
||||
<p className="s1 flex-grow text-blueGray">
|
||||
<span className="text-warmGreen">Highlighted</span> mods are recommended.
|
||||
</p>
|
||||
<TextButton
|
||||
type="button"
|
||||
loading={apply.isLoading || status?.state === 'busy'}
|
||||
onClick={() => apply.mutateAsync()}
|
||||
className={cls(status?.dirty && 'text-green')}
|
||||
>
|
||||
Apply
|
||||
</TextButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModsTab;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { type NewsItem } from '~main/types';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
|
||||
import IconSpinner from '../styled/IconSpinner';
|
||||
import TextButton from '../styled/TextButton';
|
||||
|
||||
const formatDate = (raw: string) => {
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return raw;
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const NewsEntry = ({ item }: { item: NewsItem }) => {
|
||||
const openLink = api.general.openLink.useMutation();
|
||||
return (
|
||||
<article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h5 className="tw-color">{item.title}</h5>
|
||||
<span className="s1 shrink-0 text-blueGray">{formatDate(item.date)}</span>
|
||||
</div>
|
||||
{item.author && (
|
||||
<span className="s1 italic text-blueGray">by {item.author}</span>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p>
|
||||
{item.url && (
|
||||
<TextButton
|
||||
icon={ExternalLink}
|
||||
size={14}
|
||||
className="-ml-2 self-start text-pink"
|
||||
onClick={() => openLink.mutateAsync(item.url!)}
|
||||
>
|
||||
Read more
|
||||
</TextButton>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const NewsTab = () => {
|
||||
const query = api.news.list.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1
|
||||
});
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="tw-color">News</h4>
|
||||
<TextButton
|
||||
icon={RefreshCw}
|
||||
size={18}
|
||||
className="-mr-2 text-blueGray"
|
||||
loading={query.isFetching}
|
||||
onClick={() => query.refetch()}
|
||||
title="Refresh"
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="relative -m-4 -mt-0 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pt-0"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<div className="flex flex-grow flex-col items-center justify-center gap-2">
|
||||
<IconSpinner className="text-blueGray" />
|
||||
<p className="italic text-blueGray">Loading news...</p>
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div className="flex flex-grow flex-col items-center justify-center gap-3">
|
||||
<AlertTriangle size={32} className="text-red" />
|
||||
<p className="italic text-blueGray">Couldn't reach the news feed.</p>
|
||||
<TextButton
|
||||
icon={RefreshCw}
|
||||
size={18}
|
||||
className="text-pink"
|
||||
onClick={() => query.refetch()}
|
||||
>
|
||||
Try again
|
||||
</TextButton>
|
||||
</div>
|
||||
) : !query.data?.length ? (
|
||||
<div className="flex flex-grow flex-col items-center justify-center">
|
||||
<p className="italic text-blueGray">No news yet — check back later.</p>
|
||||
</div>
|
||||
) : (
|
||||
query.data.map(item => <NewsEntry key={item.id} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsTab;
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useForm, type UseFormReturn } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { api } from '~renderer/utils/api';
|
||||
import { ConfigWtfSchema } from '~common/schemas';
|
||||
import zodResolver from '~renderer/utils/zodResolver';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
|
||||
import TextButton from '../styled/TextButton';
|
||||
import CheckboxInput from '../form/CheckboxInput';
|
||||
import NumberGrabInput from '../form/NumberGrabInput';
|
||||
|
||||
type ItemProps = {
|
||||
type?: 'checkbox' | 'number';
|
||||
id: keyof ConfigWtfSchema;
|
||||
label: string;
|
||||
recommended?: boolean;
|
||||
text: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
sensitivity?: number;
|
||||
form: UseFormReturn<ConfigWtfSchema>;
|
||||
};
|
||||
|
||||
const Item = ({
|
||||
type = 'checkbox',
|
||||
id,
|
||||
label,
|
||||
recommended,
|
||||
text,
|
||||
form,
|
||||
...props
|
||||
}: ItemProps) => {
|
||||
const { watch, setValue, register } = form;
|
||||
const setOpts = {
|
||||
shouldTouch: true,
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
} as const;
|
||||
const watched = type === 'checkbox' ? watch(id) : undefined;
|
||||
const registered = type === 'number' ? register(id) : undefined;
|
||||
return (
|
||||
<>
|
||||
<p className={cls({ 'text-warmGreen': recommended })}>{label}</p>
|
||||
{type === 'checkbox' && (
|
||||
<CheckboxInput
|
||||
value={!!watched}
|
||||
setValue={v => setValue(id, v, setOpts)}
|
||||
className="justify-self-center"
|
||||
/>
|
||||
)}
|
||||
{type === 'number' && registered && (
|
||||
<NumberGrabInput
|
||||
{...registered}
|
||||
{...props}
|
||||
setValue={v => setValue(id, v, setOpts)}
|
||||
/>
|
||||
)}
|
||||
<p className="s1 text-blueGray">{text}</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TweaksTab = () => {
|
||||
const { data: pref } = api.preferences.get.useQuery();
|
||||
const setPref = api.preferences.set.useMutation();
|
||||
|
||||
const applyPatch = api.patcher.apply.useMutation();
|
||||
const verify = api.updater.verify.useMutation();
|
||||
|
||||
const form = useForm<ConfigWtfSchema>({
|
||||
defaultValues: pref?.config ?? {},
|
||||
resolver: zodResolver(ConfigWtfSchema)
|
||||
});
|
||||
const { handleSubmit, reset } = form;
|
||||
|
||||
useEffect(() => {
|
||||
pref && reset(pref.config);
|
||||
}, [reset, pref]);
|
||||
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(async config => {
|
||||
await setPref.mutateAsync({ config });
|
||||
await applyPatch.mutateAsync();
|
||||
await verify.mutateAsync();
|
||||
|
||||
reset(config);
|
||||
})}
|
||||
className="tw-surface flex min-h-0 flex-grow flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="relative -m-4 -mb-3 grid flex-grow grid-cols-[auto_auto_1fr] content-start items-center gap-x-3 gap-y-1 overflow-y-auto p-4 pb-3"
|
||||
>
|
||||
<Item
|
||||
form={form}
|
||||
id="alwaysAutoLoot"
|
||||
label="Always auto-loot"
|
||||
text="Reverses auto-loot behavior to always auto-loot and disable auto-with bound key."
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="largeAddress"
|
||||
label="Large Address Aware"
|
||||
text="Allows the game to use more than 2GB of memory."
|
||||
recommended
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
type="number"
|
||||
id="nameplateRange"
|
||||
label="Nameplate range"
|
||||
text="Increases distance at which nameplates are visible. [Vanilla: 20] [Classic: 41]"
|
||||
min={0}
|
||||
max={41}
|
||||
/>
|
||||
|
||||
<h4 className="tw-color col-span-3 mt-3">Camera</h4>
|
||||
<Item
|
||||
form={form}
|
||||
id="fieldOfView"
|
||||
label="Field of View"
|
||||
type="number"
|
||||
text="Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]"
|
||||
min={90}
|
||||
max={180}
|
||||
step={5}
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="farClip"
|
||||
label="Render distance"
|
||||
type="number"
|
||||
text="Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]"
|
||||
min={100}
|
||||
max={10000}
|
||||
sensitivity={3}
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="frillDistance"
|
||||
label="Ground clutter distance"
|
||||
type="number"
|
||||
text="Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]"
|
||||
min={0}
|
||||
max={300}
|
||||
sensitivity={0.3}
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="cameraDistance"
|
||||
label="Camera distance"
|
||||
type="number"
|
||||
text="Increases maximum camera (zoom out) distance. [Vanilla: 50] [Max:100]"
|
||||
min={50}
|
||||
max={100}
|
||||
/>
|
||||
|
||||
<h4 className="tw-color col-span-3 mt-3">Sounds</h4>
|
||||
<Item
|
||||
form={form}
|
||||
id="soundInBackground"
|
||||
label="Background sounds"
|
||||
text="Allows game sounds to play while the game is minimized."
|
||||
recommended
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
|
||||
<p className="s1 flex-grow text-blueGray">
|
||||
<span className="s1 text-warmGreen">Highlighted</span> options are
|
||||
recommended and enabled by default
|
||||
</p>
|
||||
<TextButton
|
||||
onClick={async () => {
|
||||
const config = ConfigWtfSchema.parse({});
|
||||
await setPref.mutateAsync({ config });
|
||||
reset(config);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</TextButton>
|
||||
<TextButton type="submit" className="text-green">
|
||||
Apply
|
||||
</TextButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TweaksTab;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { type ReactNode, type PropsWithChildren } from 'react';
|
||||
import {
|
||||
AlertOctagon,
|
||||
ExternalLink,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Dot,
|
||||
DownloadCloud
|
||||
} from 'lucide-react';
|
||||
|
||||
import { type AddonData } from '~main/types';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import TextButton from '~renderer/components/styled/TextButton';
|
||||
import { ColoredText } from '~renderer/components/styled/ColoredText';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
||||
import CloseButton from '~renderer/components/styled/CloseButton';
|
||||
|
||||
import { type LocalDependencies } from './AddonListItem';
|
||||
|
||||
const AddonDetailItem = ({
|
||||
name,
|
||||
children
|
||||
}: PropsWithChildren<{ name: string }>) =>
|
||||
children ? (
|
||||
<div className="s1 pl-4 -indent-4 text-blueGray">
|
||||
{name}:{' '}
|
||||
{typeof children === 'string' ? (
|
||||
<ColoredText className="inline">{children}</ColoredText>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
type Props = AddonData & {
|
||||
close: () => void;
|
||||
warnings: { full: ReactNode; short: ReactNode }[];
|
||||
dependencies: LocalDependencies;
|
||||
};
|
||||
|
||||
const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
|
||||
const openLink = api.general.openLink.useMutation();
|
||||
const update = api.addons.update.useMutation();
|
||||
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="tw-surface flex max-h-[calc(100vh_-_256px)] w-full max-w-md flex-col gap-3 overflow-y-auto"
|
||||
>
|
||||
<CloseButton close={close} />
|
||||
<ColoredText className="text-2xl">
|
||||
{addon.toc?.Title ?? addon.folder}
|
||||
</ColoredText>
|
||||
<hr />
|
||||
{addon.error && (
|
||||
<p className="s1 text-red">
|
||||
<AlertOctagon size={14} className="inline text-inherit" />{' '}
|
||||
{addon.error}
|
||||
</p>
|
||||
)}
|
||||
{warnings.map((w, i) => (
|
||||
<p key={i} className="s1 text-yellow">
|
||||
<AlertTriangle size={14} className="inline text-inherit" /> {w.full}
|
||||
</p>
|
||||
))}
|
||||
{(addon.toc?.Notes || addon.description) && (
|
||||
<ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText>
|
||||
)}
|
||||
<div>
|
||||
<AddonDetailItem name="Source">
|
||||
{addon.git && (
|
||||
<TextButton
|
||||
onClick={() => openLink.mutateAsync(addon.git)}
|
||||
className="s1 -m-2 !inline"
|
||||
>
|
||||
Open on GitHub
|
||||
<ExternalLink size={12} className="ml-1 inline" />
|
||||
</TextButton>
|
||||
)}
|
||||
</AddonDetailItem>
|
||||
{addon.toc && (
|
||||
<>
|
||||
<AddonDetailItem name="Contributions">
|
||||
{addon.toc.Author}
|
||||
</AddonDetailItem>
|
||||
<AddonDetailItem name="Addon version">
|
||||
{addon.toc.Version}
|
||||
</AddonDetailItem>
|
||||
<AddonDetailItem name="Dependencies">
|
||||
{!!dependencies.length && (
|
||||
<ul className="pl-2">
|
||||
{dependencies.map(({ name, optional, status }) => (
|
||||
<li key={name}>
|
||||
{status === 'installed' ? (
|
||||
<Check size={16} className="inline text-darkGreen" />
|
||||
) : status === 'available' ? (
|
||||
<TextButton
|
||||
title="Download"
|
||||
icon={DownloadCloud}
|
||||
size={16}
|
||||
onClick={() =>
|
||||
update.mutateAsync({ toUpdate: [name] })
|
||||
}
|
||||
className="-m-2 !inline text-warmGreen"
|
||||
/>
|
||||
) : status === 'missing' ? (
|
||||
optional ? (
|
||||
<Dot size={16} className="inline text-blueGray" />
|
||||
) : (
|
||||
<X size={16} className="inline text-red" />
|
||||
)
|
||||
) : (
|
||||
<IconSpinner size={16} className="inline" />
|
||||
)}
|
||||
<p className="inline"> {name} </p>
|
||||
{!['installed', 'available', 'missing'].includes(
|
||||
status
|
||||
) ? (
|
||||
<p className="s1 inline text-blueGray">{status}</p>
|
||||
) : optional ? (
|
||||
<p className="s1 inline text-blueGray">(optional)</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AddonDetailItem>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AddonDetail;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import log from 'electron-log/renderer';
|
||||
|
||||
type Props = { children: ReactNode; folder: string; row: number };
|
||||
type State = { hasError: boolean; message?: string };
|
||||
|
||||
class AddonItemErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, message: error.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
log.error(
|
||||
`[AddonListItem] crash row=${this.props.row} folder=${this.props.folder}:`,
|
||||
error,
|
||||
info
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
return (
|
||||
<div
|
||||
className="contents"
|
||||
style={{ gridRow: this.props.row + 1 }}
|
||||
>
|
||||
<div style={{ gridRow: this.props.row + 1, gridColumn: '1/5' }} className="-mx-4 px-4 py-1 text-red s1">
|
||||
Failed to render "{this.props.folder}": {this.state.message ?? 'unknown error'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddonItemErrorBoundary;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { type AddonData } from '~main/types';
|
||||
|
||||
import AddonListItem, { type Dependencies } from './AddonListItem';
|
||||
import AddonItemErrorBoundary from './AddonItemErrorBoundary';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
addons: AddonData[];
|
||||
dependencies: Dependencies;
|
||||
};
|
||||
|
||||
const AddonList = ({ title, addons, dependencies }: Props) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
if (!addons.length) return null;
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="mb-2 flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0"
|
||||
>
|
||||
{open ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
<h4 className="tw-color">{title}</h4>
|
||||
</button>
|
||||
<div
|
||||
className={cls(
|
||||
'grid grid-cols-[auto_auto_1fr_auto] items-center gap-x-3 gap-y-1',
|
||||
!open && 'hidden'
|
||||
)}
|
||||
>
|
||||
{addons.map((addon, i) => {
|
||||
const { ref: gitRef, ...rest } = addon;
|
||||
return (
|
||||
<AddonItemErrorBoundary
|
||||
key={`${addon.folder}#${i}`}
|
||||
folder={addon.folder}
|
||||
row={i}
|
||||
>
|
||||
<AddonListItem
|
||||
row={i}
|
||||
dependencies={dependencies}
|
||||
gitRef={gitRef}
|
||||
{...rest}
|
||||
/>
|
||||
</AddonItemErrorBoundary>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AddonList;
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
DownloadCloud,
|
||||
Github,
|
||||
HelpCircle,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { type AddonData } from '~main/types';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import TextButton from '~renderer/components/styled/TextButton';
|
||||
import { ColoredText } from '~renderer/components/styled/ColoredText';
|
||||
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
||||
import DialogButton from '~renderer/components/styled/DialogButton';
|
||||
import { isNotUndef } from '~common/utils';
|
||||
import CloseButton from '~renderer/components/styled/CloseButton';
|
||||
|
||||
import AddonDetail from './AddonDetail';
|
||||
|
||||
export type Dependencies = {
|
||||
[folder: string]: 'installed' | 'available' | string;
|
||||
};
|
||||
|
||||
export type LocalDependencies = {
|
||||
name: string;
|
||||
optional: boolean;
|
||||
status: 'installed' | 'available' | 'missing' | string;
|
||||
}[];
|
||||
|
||||
type Props = Omit<AddonData, 'ref'> & {
|
||||
row: number;
|
||||
dependencies: Dependencies;
|
||||
gitRef?: string;
|
||||
};
|
||||
|
||||
const toRepoUrl = (git?: string) =>
|
||||
git ? git.replace(/\.git$/, '') : undefined;
|
||||
|
||||
const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
||||
const update = api.addons.update.useMutation();
|
||||
const remove = api.addons.remove.useMutation();
|
||||
const openLink = api.general.openLink.useMutation();
|
||||
const repoUrl = toRepoUrl(addon.git);
|
||||
|
||||
const localDependencies: LocalDependencies = [
|
||||
...(addon.toc?.Dependencies?.split(', ')?.map(d => [d, false] as const) ??
|
||||
[]),
|
||||
...(addon.toc?.OptionalDeps?.split(', ')?.map(d => [d, true] as const) ??
|
||||
[])
|
||||
].map<LocalDependencies[number]>(([d, optional]) => ({
|
||||
name: d,
|
||||
optional,
|
||||
status: dependencies[d] ?? 'missing'
|
||||
}));
|
||||
|
||||
const warnings = [
|
||||
addon.toc && addon.toc?.Interface !== '11200'
|
||||
? {
|
||||
full: `This addon seems to be made for different game version (${addon.toc?.Interface}) and it may not function correctly`,
|
||||
short: 'Incorrect version'
|
||||
}
|
||||
: undefined,
|
||||
localDependencies.some(d => d.status !== 'installed' && !d.optional)
|
||||
? {
|
||||
full: `This addon has missing dependencies: ${localDependencies
|
||||
.filter(d => d.status !== 'installed' && !d.optional)
|
||||
.map(d => d.name)
|
||||
.join(', ')}`,
|
||||
short: 'Missing dependencies'
|
||||
}
|
||||
: undefined
|
||||
].filter(isNotUndef);
|
||||
|
||||
return (
|
||||
<div className="contents hover-row:bg-purple/30">
|
||||
<div
|
||||
className="-mx-4 h-full w-[200%]"
|
||||
style={{ gridRow: row + 1, gridColumn: '1/4' }}
|
||||
/>
|
||||
{addon.status === 'fetching' ? (
|
||||
<IconSpinner
|
||||
className="text-blueGray"
|
||||
size={18}
|
||||
style={{ gridRow: row + 1, gridColumn: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<DialogButton
|
||||
clickAway
|
||||
dialog={close => (
|
||||
<AddonDetail
|
||||
close={close}
|
||||
warnings={warnings}
|
||||
dependencies={localDependencies}
|
||||
{...addon}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{open => (
|
||||
<TextButton
|
||||
icon={
|
||||
addon.status === 'invalid'
|
||||
? AlertOctagon
|
||||
: warnings.length
|
||||
? AlertTriangle
|
||||
: HelpCircle
|
||||
}
|
||||
onClick={open}
|
||||
title="Details"
|
||||
size={18}
|
||||
className={cls(
|
||||
'-mx-2',
|
||||
addon.status === 'invalid'
|
||||
? 'text-red'
|
||||
: warnings.length
|
||||
? 'text-yellow'
|
||||
: 'text-blueGray'
|
||||
)}
|
||||
style={{ gridRow: row + 1, gridColumn: 1 }}
|
||||
/>
|
||||
)}
|
||||
</DialogButton>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="-ml-2 flex items-center gap-1 whitespace-nowrap"
|
||||
style={{ gridRow: row + 1, gridColumn: 2 }}
|
||||
>
|
||||
<ColoredText>{addon.toc?.Title ?? addon.folder}</ColoredText>
|
||||
{repoUrl && (
|
||||
<TextButton
|
||||
icon={Github}
|
||||
size={14}
|
||||
title={`Open ${repoUrl} on GitHub`}
|
||||
onClick={() => openLink.mutateAsync(repoUrl)}
|
||||
className="!p-1 text-blueGray/60 hocus:text-pink"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ColoredText
|
||||
className="s1 py-1 text-blueGray"
|
||||
style={{ gridRow: row + 1, gridColumn: 3 }}
|
||||
>
|
||||
{addon.toc?.Notes ?? addon.description ?? ''}
|
||||
</ColoredText>
|
||||
|
||||
<div
|
||||
className="-m-2 flex items-center justify-end gap-2"
|
||||
style={{ gridRow: row + 1, gridColumn: 4 }}
|
||||
>
|
||||
{addon.status === 'downloading' ? (
|
||||
<>
|
||||
<p className="s1 text-blueGray">{addon.progress}</p>
|
||||
<IconSpinner size={18} className="text-blueGray" />
|
||||
</>
|
||||
) : addon.status === 'invalid' ? (
|
||||
<p className="s1 text-red">{addon.error}</p>
|
||||
) : warnings.length ? (
|
||||
<p className="s1 text-yellow">{warnings[0].short}</p>
|
||||
) : (
|
||||
<p className="s1 text-blueGray/50">
|
||||
{addon.status === 'upToDate'
|
||||
? 'Up to date'
|
||||
: !addon.git
|
||||
? 'Not versioned'
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{addon.status === 'outOfDate' && (
|
||||
<TextButton
|
||||
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
|
||||
className="s1 -mx-2 justify-self-end"
|
||||
>
|
||||
Update
|
||||
</TextButton>
|
||||
)}
|
||||
{addon.status === 'available' ? (
|
||||
<TextButton
|
||||
// TODO: With dependencies checkbox
|
||||
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
|
||||
className="text-warmGreen"
|
||||
icon={DownloadCloud}
|
||||
size={18}
|
||||
title="Download"
|
||||
/>
|
||||
) : (
|
||||
<DialogButton
|
||||
clickAway
|
||||
dialog={close => (
|
||||
<div className="tw-dialog">
|
||||
<CloseButton close={close} />
|
||||
<h4 className="tw-color">Are you sure?</h4>
|
||||
<hr />
|
||||
<p className="text-blueGray">
|
||||
Are you sure you want to delete <span>{addon.folder}</span>{' '}
|
||||
addon?
|
||||
</p>
|
||||
<p className="text-blueGray">
|
||||
This will delete all files in the addon folder.
|
||||
</p>
|
||||
<TextButton
|
||||
icon={Trash2}
|
||||
onClick={async () => {
|
||||
await remove.mutateAsync({ toDelete: [addon.folder] });
|
||||
close();
|
||||
}}
|
||||
disabled={remove.isLoading}
|
||||
className="self-end text-red"
|
||||
>
|
||||
Delete
|
||||
</TextButton>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{open => (
|
||||
<TextButton
|
||||
onClick={open}
|
||||
className="text-red/50"
|
||||
icon={Trash2}
|
||||
size={18}
|
||||
title="Remove"
|
||||
/>
|
||||
)}
|
||||
</DialogButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddonListItem;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CloseButton from '~renderer/components/styled/CloseButton';
|
||||
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
||||
import TextButton from '~renderer/components/styled/TextButton';
|
||||
import { api } from '~renderer/utils/api';
|
||||
|
||||
const useDebounced = (value: string, delay: number) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
const CustomAddonDialog = ({ close }: { close: () => void }) => {
|
||||
const [url, setUrl] = useState('');
|
||||
const debouncedUrl = useDebounced(url, 500);
|
||||
const response = api.addons.checkGitUrl.useQuery(debouncedUrl, {
|
||||
enabled: !!debouncedUrl
|
||||
});
|
||||
const update = api.addons.install.useMutation();
|
||||
|
||||
return (
|
||||
<div className="tw-dialog">
|
||||
<CloseButton close={close} />
|
||||
<h3 className="tw-color">Install addon</h3>
|
||||
<hr />
|
||||
{response.data ? (
|
||||
<img src={response.data?.preview} alt="Preview" className="w-full" />
|
||||
) : (
|
||||
<div className="flex h-[191px] w-full items-center justify-center bg-darkPurple">
|
||||
{response.isFetching && <IconSpinner />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 border-b border-blueGray bg-darkGray/70 p-1 hocus:border-orange">
|
||||
<input
|
||||
className="w-full cursor-text bg-inherit"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
/>
|
||||
{response.isFetching ? (
|
||||
<IconSpinner size={18} />
|
||||
) : response.data ? (
|
||||
<Check size={18} />
|
||||
) : (
|
||||
<X size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<p className="s1 text-blueGray">
|
||||
{response.data
|
||||
? 'Ready to install'
|
||||
: 'Not a valid git repository URL'}
|
||||
</p>
|
||||
<TextButton
|
||||
onClick={() => {
|
||||
if (!response.data) return;
|
||||
update.mutateAsync(response.data);
|
||||
close();
|
||||
setUrl('');
|
||||
}}
|
||||
className={response.data ? 'text-warmGreen' : 'text-blueGray'}
|
||||
disabled={!response.data || response.isLoading}
|
||||
>
|
||||
Install
|
||||
</TextButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomAddonDialog;
|
||||
Reference in New Issue
Block a user