Initial commit
Build check / build (push) Has been cancelled

This commit is contained in:
2026-05-07 20:06:01 -07:00
commit ec0557204c
110 changed files with 18550 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
import { useForm } from 'react-hook-form';
import { useEffect } from 'react';
import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
import { api } from '~renderer/utils/api';
import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput';
import CloseButton from './styled/CloseButton';
type Props = { close: () => void };
const ClientDirDialog = ({ close }: Props) => {
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const isValidClientDir = api.preferences.isValidClientDir.useQuery(
pref?.clientDir,
{ enabled: !!pref?.isPortable }
);
const verify = api.updater.verify.useMutation();
const {
register,
handleSubmit,
watch,
formState,
setValue,
setError,
reset
} = useForm({
defaultValues: { clientDir: pref?.clientDir ?? '' },
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true }))
});
useEffect(() => {
pref && reset(pref);
}, [reset, pref]);
if (pref?.isPortable) {
return (
<form className="tw-dialog">
<CloseButton close={close} />
<h2 className="color mb-2 text-xl">Install location</h2>
<p>
You are using the portable version of the launcher. Install location
is determined by the location of the launcher executable.
</p>
{!isValidClientDir.isLoading && !isValidClientDir.data && (
<p>
<span className="text-secondary">Error: </span>
WoW.exe not found in current folder. Please close the launcher and
move it to your WoW 1.12 client directory.
</p>
)}
</form>
);
}
return (
<form
className="tw-dialog"
onSubmit={handleSubmit(async ({ clientDir }) => {
try {
await setPref.mutateAsync({ clientDir });
verify.mutateAsync();
close();
} catch (e) {
setError('clientDir', {
message: e instanceof Error ? e.message : JSON.stringify(e)
});
}
})}
>
<CloseButton
close={() => {
reset();
close();
}}
/>
<h3 className="tw-color">Install location</h3>
<hr />
<p className="text-blueGray">
Select a directory for the game client installation.
</p>
<p className="text-blueGray">
You may also choose a directory with an existing Turtle WoW or Vanilla
WoW installation, and it will be automatically upgraded.
</p>
<div className="flex items-center gap-3">
<label htmlFor="clientDir">Install directory:</label>
<FilePickerInput
{...register('clientDir')}
title={watch('clientDir') ?? undefined}
setValue={v =>
setValue('clientDir', v, {
shouldTouch: true,
shouldDirty: true,
shouldValidate: true
})
}
options={{ properties: ['openDirectory', 'createDirectory'] }}
/>
</div>
{formState.errors.clientDir && (
<p className="text-secondary text-sm">
{formState.errors.clientDir.message}
</p>
)}
<TextButton
type="submit"
loading={formState.isSubmitting}
className="self-end text-green"
>
Confirm
</TextButton>
</form>
);
};
export default ClientDirDialog;
+32
View File
@@ -0,0 +1,32 @@
import OctoLogo from '~renderer/assets/logo.png';
import TextButton from './styled/TextButton';
import { TabNames, type TabType } from './TabsPanel';
type Props = {
activeTab?: TabType;
setActiveTab: (tab?: TabType) => void;
};
const Header = ({ activeTab, setActiveTab }: Props) => (
<div className="-mb-3 flex select-none items-center gap-1">
<button
onClick={() => setActiveTab(undefined)}
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
>
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
</button>
{TabNames.map(t => (
<TextButton
key={t}
onClick={() => setActiveTab(t)}
active={activeTab === t}
className="uppercase"
>
{t}
</TextButton>
))}
</div>
);
export default Header;
+218
View File
@@ -0,0 +1,218 @@
import { useState, type ReactElement } from 'react';
import cls from 'classnames';
import { type UpdaterStatus, type ModsStatus } from '~main/types';
import { formatFileSize } from '~common/utils';
import { api } from '~renderer/utils/api';
import Button from './styled/Button';
import DialogButton from './styled/DialogButton';
import ClientDirDialog from './ClientDirDialog';
const formatDuration = (seconds: number) => {
const s = Math.max(0, Math.round(seconds));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`;
const h = Math.floor(m / 60);
const minRem = m % 60;
return minRem ? `${h}h ${minRem}m` : `${h}h`;
};
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } = status;
if (bytesTotal === undefined || bytesDone === undefined) return null;
const pct = progress !== undefined && progress >= 0
? `${(progress * 100).toFixed(1)}%`
: '—';
return (
<p className="s1 text-blueGray">
<span className="tw-color">{pct}</span>
<span> · {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}</span>
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
<span> · {formatFileSize(bytesPerSecond)}/s</span>
)}
<span>
{' · '}
{etaSeconds !== undefined
? `~${formatDuration(etaSeconds)} remaining`
: 'calculating…'}
</span>
</p>
);
};
const LaunchPanel = () => {
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
api.updater.observe.useSubscription(undefined, {
onData: data => {
console.log({ data });
setStatus(data);
},
onError: err => console.log({ err }),
onStarted: () => console.log('Started')
});
const { data: pref } = api.preferences.get.useQuery();
const [modsStatus, setModsStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, {
onData: setModsStatus
});
const verify = api.updater.verify.useMutation();
const update = api.updater.update.useMutation();
const start = api.launcher.start.useMutation();
const applyMods = api.mods.applyAll.useMutation();
const props: Record<
UpdaterStatus['state'],
{ button: ReactElement; helperText?: ReactElement }
> = {
verifying: { button: <Button disabled>Verifying</Button> },
serverUnreachable: {
button: pref?.version ? (
<Button onClick={() => start.mutateAsync()}>Play</Button>
) : (
<Button onClick={() => verify.mutateAsync()}>Retry</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">Error: </span> Failed to reach update
server
</p>
<p className="s1 text-blueGray">
{pref?.version
? `You can launch local version ${pref?.version}`
: 'Please try again later'}
</p>
</div>
)
},
noClient: {
button: (
<DialogButton
clickAway
dialog={close => <ClientDirDialog close={close} />}
>
{open => (
<Button primary onClick={open}>
Install
</Button>
)}
</DialogButton>
)
},
updateAvailable: {
button: <Button onClick={() => update.mutateAsync()}>Update</Button>,
helperText: (
<div className="-mb-2 flex flex-col gap-1">
<p>Update available!</p>
<p className="s1 text-blueGray">
{status.progress !== undefined &&
status.bytesDone !== undefined &&
status.bytesTotal !== undefined && (
<>
<span className="tw-color">
{(status.progress * 100).toFixed(1)}%
</span>
<span>
{' '}
· {formatFileSize(status.bytesDone)} /{' '}
{formatFileSize(status.bytesTotal)} on disk ·{' '}
</span>
</>
)}
<span className="break-all">{status.message}</span> remaining
</p>
</div>
)
},
updating: {
button: <Button disabled>Updating</Button>,
helperText: (
<div className="-mb-2 flex flex-col gap-1">
{status.message && (
<p className="s1 truncate text-blueGray">{status.message}</p>
)}
<ProgressDetails status={status} />
</div>
)
},
upToDate: {
button: modsStatus?.dirty ? (
<Button
primary
onClick={() => applyMods.mutateAsync()}
disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
>
{modsStatus?.state === 'busy' ? 'Applying' : 'Update'}
</Button>
) : (
<Button primary onClick={() => start.mutateAsync()}>
Play
</Button>
),
helperText: (
<div className="-mb-2">
{modsStatus?.dirty ? (
<p>Mods changed apply before playing</p>
) : (
<p>Everything up to date!</p>
)}
<p className="s1 text-blueGray">Version: {pref?.version}</p>
</div>
)
},
failed: {
button: <Button onClick={() => verify.mutateAsync()}>Retry</Button>,
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">Error: </span>
{status.message}
</p>
<p className="s1 text-blueGray">
Verify your game data by clicking Retry.
</p>
</div>
)
}
};
return (
<div className="flex gap-3">
<div className="flex flex-grow flex-col justify-end gap-3">
{props[status.state].helperText ??
(status.message && (
<p className="s1 -mb-2 text-blueGray">{status.message}</p>
))}
<div className="tw-loading-wrapper">
{status.progress !== undefined && (
<div
className={cls('tw-loading', {
'tw-loading-unknown': status.progress === -1
})}
style={
status.progress !== -1
? {
clipPath: `inset(0 ${
100 - Math.ceil(Math.abs(status.progress) * 100)
}% 0 0)`
}
: undefined
}
/>
)}
</div>
</div>
{props[status.state].button}
</div>
);
};
export default LaunchPanel;
@@ -0,0 +1,171 @@
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
import {
FilePen,
FolderOpen,
RefreshCw,
ScrollText,
ShieldCheck
} from 'lucide-react';
import { PreferencesSchema } from '~common/schemas';
import { api } from '~renderer/utils/api';
import zodResolver from '~renderer/utils/zodResolver';
import TextButton from './styled/TextButton';
import CheckboxInput from './form/CheckboxInput';
import DialogButton from './styled/DialogButton';
import ClientDirDialog from './ClientDirDialog';
import CloseButton from './styled/CloseButton';
const MirrorStatus = () => {
const [state, setState] = useState<string>('verifying');
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => setState(state)
});
if (state === 'serverUnreachable')
return <span className="s1 text-red">offline</span>;
if (state === 'verifying' || state === 'updating')
return <span className="s1 text-blueGray">checking</span>;
return <span className="s1 text-warmGreen">online</span>;
};
type Props = { close: () => void };
const PreferencesDialog = ({ close }: Props) => {
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const verify = api.updater.verify.useMutation();
const openInstallFolder = api.general.openInstallFolder.useMutation();
const openLogFile = api.general.openLogFile.useMutation();
const { handleSubmit, watch, setValue, reset } = useForm({
defaultValues: pref ?? {},
resolver: zodResolver(PreferencesSchema)
});
useEffect(() => {
pref && reset(pref);
}, [reset, pref]);
const setBool = (key: keyof PreferencesSchema) => (v: boolean) =>
setValue(key, v, {
shouldTouch: true,
shouldDirty: true,
shouldValidate: true
});
return (
<form
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1 whitespace-nowrap"
onSubmit={handleSubmit(async v => {
await setPref.mutateAsync(v);
close();
})}
>
<CloseButton
close={() => {
reset();
close();
}}
/>
<h3 className="tw-color">SETTINGS</h3>
<hr className="mb-1" />
<div className="flex items-center gap-3">
<h4 className="tw-color">INSTALL LOCATION:</h4>
<TextButton
icon={FolderOpen}
size={14}
onClick={() => openInstallFolder.mutateAsync()}
className="!p-1 text-blueGray"
>
Open folder
</TextButton>
</div>
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
<span
title={pref?.clientDir}
className="min-w-0 shrink grow overflow-hidden text-ellipsis"
>
{pref?.clientDir ?? 'Not selected'}
</span>
<DialogButton
dialog={closeInner => (
<ClientDirDialog
close={() => {
closeInner();
close();
}}
/>
)}
clickAway={pref?.isPortable}
>
{open => (
<TextButton icon={FilePen} size={14} onClick={open} className="!p-1">
Change
</TextButton>
)}
</DialogButton>
</div>
<div className="mt-1 flex items-center gap-3">
<h4 className="tw-color">DOWNLOAD MIRROR:</h4>
</div>
<div className="flex items-center gap-2 pl-2">
<input type="radio" checked readOnly className="accent-warmGreen" />
<span>Iceland</span>
<MirrorStatus />
<TextButton
icon={RefreshCw}
size={12}
onClick={() => verify.mutateAsync()}
title="Re-check"
className="!p-0 text-blueGray"
/>
</div>
<div className="flex items-start gap-3">
<div className="flex flex-col">
<h4 className="tw-color">TROUBLESHOOTING:</h4>
<TextButton
icon={ShieldCheck}
onClick={() => verify.mutateAsync().then(close)}
className="text-warmGreen"
>
Verify game files
</TextButton>
<TextButton
icon={ScrollText}
onClick={() => openLogFile.mutateAsync()}
className="text-pink"
>
Open log file
</TextButton>
</div>
<div className="flex flex-col">
<h4 className="tw-color">GENERAL SETTINGS:</h4>
<CheckboxInput
value={!!watch('cleanWdb')}
setValue={setBool('cleanWdb')}
label="Clean WDB on each launch"
/>
<CheckboxInput
value={!!watch('minimizeToTrayOnPlay')}
setValue={setBool('minimizeToTrayOnPlay')}
label="Minimize to tray while playing"
/>
</div>
</div>
<TextButton type="submit" className="mt-1 self-end text-green">
Save
</TextButton>
</form>
);
};
export default PreferencesDialog;
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import Button from './styled/Button';
type Status =
| { state: 'idle'; currentVersion: string }
| { state: 'checking'; currentVersion: string }
| { state: 'unavailable'; currentVersion: string }
| { state: 'available'; currentVersion: string; nextVersion: string }
| {
state: 'downloading';
currentVersion: string;
nextVersion: string;
progress: number;
}
| { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: string };
const SelfUpdateBanner = () => {
const [status, setStatus] = useState<Status>({
state: 'idle',
currentVersion: ''
});
api.selfUpdater.observe.useSubscription(undefined, {
onData: setStatus
});
const install = api.selfUpdater.install.useMutation();
if (
status.state === 'idle' ||
status.state === 'checking' ||
status.state === 'unavailable'
) {
return null;
}
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
const label =
status.state === 'error'
? `Update check failed: ${status.message}`
: status.state === 'available'
? `Launcher update ${'nextVersion' in status ? status.nextVersion : ''} available — preparing download…`
: status.state === 'downloading'
? `Downloading update ${status.nextVersion} · ${Math.round(
status.progress * 100
)}%`
: status.state === 'ready'
? `Launcher update ${status.nextVersion} ready to install`
: '';
return (
<div
className={`relative z-10 flex items-center gap-3 rounded-md border ${tone} bg-black/60 px-4 py-2 text-sm`}
>
<span className="flex-grow break-all">{label}</span>
{status.state === 'ready' && (
<Button
primary
onClick={() => install.mutateAsync()}
disabled={install.isLoading}
>
Install now
</Button>
)}
</div>
);
};
export default SelfUpdateBanner;
@@ -0,0 +1,68 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
import TextButton from './styled/TextButton';
type Props = {
tabName: string;
children: ReactNode;
};
type State = {
error?: Error;
componentStack?: string;
};
class TabErrorBoundary extends Component<Props, State> {
state: State = {};
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
log.error(`Tab "${this.props.tabName}" crashed:`, error, info);
this.setState({ error, componentStack: info.componentStack ?? undefined });
}
componentDidUpdate(prevProps: Props) {
if (prevProps.tabName !== this.props.tabName) {
this.setState({ error: undefined, componentStack: undefined });
}
}
#reset = () => this.setState({ error: undefined, componentStack: undefined });
render() {
if (!this.state.error) return this.props.children;
const { error, componentStack } = this.state;
return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-center gap-2">
<AlertTriangle size={22} className="text-red" />
<h4 className="text-red">{this.props.tabName} crashed</h4>
</div>
<hr />
<p className="text-white">
{error.name}: {error.message}
</p>
{componentStack && (
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
{componentStack.trim()}
</pre>
)}
<hr />
<TextButton
icon={RefreshCw}
onClick={this.#reset}
className="self-end text-warmGreen"
>
Try again
</TextButton>
</div>
);
}
}
export default TabErrorBoundary;
+30
View File
@@ -0,0 +1,30 @@
import AddonsTab from './tabs/AddonsTab';
import ModsTab from './tabs/ModsTab';
import NewsTab from './tabs/NewsTab';
import TweaksTab from './tabs/TweaksTab';
import TabErrorBoundary from './TabErrorBoundary';
const Tabs = {
'news': NewsTab,
'tweaks': TweaksTab,
'addons': AddonsTab,
'mods': ModsTab
} as const;
export const TabNames = Object.keys(Tabs) as TabType[];
export type TabType = keyof typeof Tabs;
type Props = { activeTab?: TabType };
const TabsPanel = ({ activeTab }: Props) => {
const tab: TabType = activeTab ?? 'news';
const Component = Tabs[tab];
return (
<TabErrorBoundary key={tab} tabName={tab}>
<Component />
</TabErrorBoundary>
);
};
export default TabsPanel;
+79
View File
@@ -0,0 +1,79 @@
import { Settings, Minus, X } from 'lucide-react';
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import DialogButton from './styled/DialogButton';
import PreferencesDialog from './PreferencesDialog';
import TextButton from './styled/TextButton';
const TopBar = () => {
const [safeToQuit, setSafeToQuit] = useState(true);
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) =>
setSafeToQuit(state !== 'verifying' && state !== 'updating')
});
const minimize = api.general.minimize.useMutation();
const quit = api.general.quit.useMutation();
return (
<div
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50"
>
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} className="flex">
<DialogButton dialog={close => <PreferencesDialog close={close} />}>
{open => (
<TextButton
icon={Settings}
title="Settings"
onClick={open}
size={16}
className="!p-1"
/>
)}
</DialogButton>
<TextButton
icon={Minus}
title="Minimize"
onClick={() => minimize.mutateAsync()}
size={16}
className="!p-1"
/>
<DialogButton
dialog={close => (
<div className="tw-dialog">
<h3 className="tw-color">Quit?</h3>
<hr />
<p className="text-blueGray">
Your game is currently being updated. Quitting now may cause
problems.
</p>
<div className="flex gap-2 self-end">
<TextButton onClick={close}>Return</TextButton>
<TextButton
onClick={() => quit.mutateAsync()}
className="text-red"
>
Quit
</TextButton>
</div>
</div>
)}
>
{open => (
<TextButton
icon={X}
title="Quit"
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
size={16}
className="!p-1 hocus:text-red"
/>
)}
</DialogButton>
</div>
</div>
);
};
export default TopBar;
@@ -0,0 +1,50 @@
import cls from 'classnames';
import { type ReactNode } from 'react';
import TextButton from '../styled/TextButton';
const Checkbox = () => (
<svg
width={16}
height={16}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
>
<rect
x="1"
y="1"
width="10"
height="10"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect x="3.5" y="3.5" width="5" height="5" fill="white" />
</svg>
);
type Props = {
label?: ReactNode;
value: boolean;
setValue: (v: boolean) => void;
disabled?: boolean;
className?: cls.Value;
};
const CheckboxInput = ({ label, value, setValue, disabled, className }: Props) => (
<TextButton
onClick={() => !disabled && setValue(!value)}
icon={Checkbox}
className={cls(
'text-blueGray',
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
className
)}
>
{label}
</TextButton>
);
export default CheckboxInput;
@@ -0,0 +1,47 @@
import cls from 'classnames';
import { forwardRef, type HTMLProps } from 'react';
import { AppWindow, FolderOpen } from 'lucide-react';
import { api, type RouterInputs } from '~renderer/utils/api';
import TextButton from '../styled/TextButton';
type Props = HTMLProps<HTMLInputElement> & {
setValue: (newVal: string) => void;
options: RouterInputs['general']['filePicker'];
};
const FilePickerInput = forwardRef<HTMLInputElement, Props>(
({ setValue, options, className, ...props }, ref) => {
const filePicker = api.general.filePicker.useMutation();
return (
<div className="relative flex grow">
<input
ref={ref}
id={props.name}
{...props}
className={cls(
'grow border-b border-blueGray bg-inherit p-1 pr-[44px] hocus:border-orange',
className
)}
/>
<TextButton
className="absolute right-1 top-0 h-full"
icon={
options.properties?.includes('openDirectory')
? FolderOpen
: AppWindow
}
title="Pick file"
onClick={async () => {
const r = await filePicker.mutateAsync(options);
if (r.canceled) return;
setValue(r.path[0]);
}}
/>
</div>
);
}
);
export default FilePickerInput;
@@ -0,0 +1,59 @@
import cls from 'classnames';
import { type ChangeEvent, type FocusEvent, type HTMLProps, forwardRef } from 'react';
type Props = Omit<
HTMLProps<HTMLInputElement>,
'value' | 'min' | 'max' | 'step'
> & {
setValue: (v: number) => void;
min?: number;
max?: number;
step?: number;
sensitivity?: number;
};
const NumberGrabInput = forwardRef<HTMLInputElement, Props>(
(
{
setValue,
className,
max = Infinity,
min = -Infinity,
step: _step,
sensitivity: _sensitivity,
type: _ignored,
onChange,
onBlur,
...props
},
ref
) => (
<input
ref={ref}
type="text"
inputMode="numeric"
{...props}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const n = Number(e.currentTarget.value);
if (Number.isFinite(n) && n > max) {
e.currentTarget.value = String(max);
}
onChange?.(e);
}}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
const n = Number(e.currentTarget.value);
const clamped = Math.max(
Math.min(Number.isFinite(n) ? n : min, max),
min
);
setValue(clamped);
onBlur?.(e);
}}
onWheel={e => !e.shiftKey && e.currentTarget.blur()}
className={cls(
className,
'w-[70px] cursor-text border-b border-blueGray bg-inherit p-1 text-center hocus:border-orange'
)}
/>
)
);
export default NumberGrabInput;
@@ -0,0 +1,44 @@
import cls from 'classnames';
import TextButton from '../styled/TextButton';
const Radio = () => (
<svg
width={16}
height={16}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="6" cy="6" r="5.5" stroke="currentColor" />
<path
d="M8 6C8 7.10429 7.10429 8 6 8C4.89571 8 4 7.10429 4 6C4 4.8957 4.89571 4 6 4C7.10429 4 8 4.8957 8 6Z"
fill="white"
/>
</svg>
);
type Props<T> = {
value: T;
setValue: (val: T) => void;
options: { label: string; value: T }[];
};
const RadioInput = <const T,>({ value, setValue, options }: Props<T>) => (
<div className="flex justify-start">
{options.map(o => (
<TextButton
key={`${o.value}`}
onClick={() => setValue(o.value)}
icon={Radio}
className={cls('text-blueGray', {
'[&_*]:fill-none': value !== o.value
})}
>
{o.label}
</TextButton>
))}
</div>
);
export default RadioInput;
@@ -0,0 +1,17 @@
import cls from 'classnames';
import { forwardRef, type HTMLProps } from 'react';
const TextInput = forwardRef<HTMLInputElement, HTMLProps<HTMLInputElement>>(
(props, ref) => (
<input
ref={ref}
{...props}
className={cls(
'cursor-text border-b border-blueGray bg-inherit p-1 hocus:border-orange',
props.className
)}
/>
)
);
export default TextInput;
+44
View File
@@ -0,0 +1,44 @@
import type { ButtonHTMLAttributes } from 'react';
import cls from 'classnames';
import { type LucideIcon } from 'lucide-react';
import IconSpinner from './IconSpinner';
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
primary?: boolean;
loading?: boolean;
disabled?: boolean;
icon?: LucideIcon;
};
const Button = ({
primary,
loading,
disabled,
icon: Icon,
children,
className,
...props
}: Props) => (
<button
{...props}
onClick={props.onClick}
tabIndex={!!loading || !!disabled ? -1 : props.tabIndex}
className={cls('tw-button', className, {
'pointer-events-none': !!disabled || !!loading,
'grayscale': disabled,
'tw-button-primary': primary
})}
>
<span className={cls('select-none', { 'ml-[-12px]': !!loading || !!Icon })}>
{loading ? (
<IconSpinner size={23} strokeWidth={1.5} />
) : Icon ? (
<Icon size={23} strokeWidth={1.5} />
) : null}
{children}
</span>
</button>
);
export default Button;
@@ -0,0 +1,14 @@
import { X } from 'lucide-react';
import TextButton from './TextButton';
const CloseButton = ({ close }: { close: () => void }) => (
<TextButton
title="Close"
icon={X}
size={16}
onClick={close}
className="absolute right-1 top-1 text-blueGray hocus:text-red"
/>
);
export default CloseButton;
@@ -0,0 +1,54 @@
type Run = { text: string; color?: string };
const tokenize = (s: string): Run[] => {
const runs: Run[] = [];
const re = /\|c([0-9a-fA-F]{8})|\|r/g;
let i = 0;
let color: string | undefined;
let m: RegExpExecArray | null;
while ((m = re.exec(s)) !== null) {
if (m.index > i) runs.push({ text: s.slice(i, m.index), color });
if (m[0].toLowerCase() === '|r') {
color = undefined;
} else if (m[1]) {
color = `#${m[1].slice(2).toLowerCase()}`;
}
i = re.lastIndex;
}
if (i < s.length) runs.push({ text: s.slice(i), color });
return runs.filter(r => r.text.length > 0);
};
export const stripColorCodes = (s: string) =>
tokenize(s)
.map(r => r.text)
.join('');
export const ColoredText = ({
children,
className,
style
}: {
children: string;
className?: string;
style?: React.CSSProperties;
}) => {
const runs = tokenize(children);
return (
<p className={className} style={style}>
{runs.map((r, i) =>
r.color ? (
<span
key={i}
className="text-size-inherit text-inherit"
style={{ color: r.color }}
>
{r.text}
</span>
) : (
<span key={i}>{r.text}</span>
)
)}
</p>
);
};
@@ -0,0 +1,80 @@
import cls from 'classnames';
import {
useRef,
type ReactElement,
useEffect,
useCallback,
type FC,
isValidElement
} from 'react';
import { createPortal } from 'react-dom';
type Props = {
clickAway?: boolean;
noBlur?: boolean;
focusOnOpen?: boolean;
afterClose?: () => void;
dialog: ReactElement | ((close: () => void) => ReactElement);
children: ReactElement | ((open: () => void) => ReactElement);
};
const DialogButton = ({
clickAway,
noBlur,
focusOnOpen,
afterClose,
dialog,
children
}: Props) => {
const ref = useRef<HTMLDialogElement>(null);
const open = useCallback(() => {
if (!ref.current) return;
!focusOnOpen && (ref.current.inert = true);
ref.current.showModal();
!focusOnOpen && (ref.current.inert = false);
}, [focusOnOpen]);
const close = useCallback(() => {
ref.current?.close();
}, []);
// Click away
useEffect(() => {
if (!clickAway) return;
const callback = (e: MouseEvent) => e.target === ref.current && close();
window.addEventListener('click', callback);
return () => window.removeEventListener('click', callback);
}, [clickAway, close]);
useEffect(() => {
const callback = () => {
afterClose?.();
return (document.activeElement as HTMLElement)?.blur();
};
const r = ref.current;
r?.addEventListener('close', callback);
return () => r?.removeEventListener('close', callback);
}, [afterClose]);
return (
<>
{createPortal(
<dialog
ref={ref}
onSubmit={e => e.stopPropagation()}
className={cls(
'h-full w-full items-center justify-center bg-[transparent] [&[open]]:flex',
{ 'backdrop:backdrop-blur-md': !noBlur }
)}
>
{typeof dialog === 'function' ? dialog(close) : dialog}
</dialog>,
document.body
)}
{typeof children === 'function' ? children(open) : children}
</>
);
};
export default DialogButton;
@@ -0,0 +1,8 @@
import cls from 'classnames';
import { Loader2, type LucideProps } from 'lucide-react';
const IconSpinner = ({ className, ...props }: LucideProps) => (
<Loader2 {...props} className={cls(className, 'animate-spin')} />
);
export default IconSpinner;
@@ -0,0 +1,66 @@
import cls from 'classnames';
import { type LucideIcon } from 'lucide-react';
import { type ReactNode } from 'react';
import IconSpinner from './IconSpinner';
type Props = {
active?: boolean;
loading?: boolean;
disabled?: boolean;
size?: number;
className?: cls.Value;
style?: React.CSSProperties;
} & (
| { type: 'submit'; onClick?: never }
| { type?: never; onClick: () => void }
) &
(
| { children: ReactNode; icon?: LucideIcon; title?: never }
| { children?: never; icon: LucideIcon; title: string }
);
const TextButton = ({
title,
type,
active,
loading,
disabled,
icon: Icon,
size,
onClick,
className,
children,
...props
}: Props) => (
<button
title={title ?? (typeof children === 'string' ? children : undefined)}
type={type ?? 'button'}
onClick={onClick}
tabIndex={!!loading || !!disabled ? -1 : undefined}
className={cls(
'flex cursor-pointer items-center gap-2 border-0 p-2',
className,
{
'tw-color drop-shadow-[0px_0px_10px_white]':
active && !loading && !disabled,
'pointer-events-none text-gray': !!loading || !!disabled,
'tw-hocus': !loading && !disabled
}
)}
{...props}
>
{loading ? (
<IconSpinner size={size ?? 24} strokeWidth={1.5} />
) : (
Icon && <Icon size={size} />
)}
{children && (
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
{children}
</span>
)}
</button>
);
export default TextButton;
+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;