Initial commit

This commit is contained in:
2026-05-08 00:00:00 +00:00
commit 530ec7a144
110 changed files with 18537 additions and 0 deletions
+131
View File
@@ -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;
+123
View File
@@ -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;
+102
View File
@@ -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;
+196
View File
@@ -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 &quot;{this.props.folder}&quot;: {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;