@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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