OctoLauncher 1.3.1

Manifest-based CDN updater and mod manager for the OctoWoW 1.12.1 client:
launcher-owned realmlist, torrent-backed content sync with bundled aria2c,
antivirus and Defender exclusion handling, hardware-aware render distance,
optional client tweaks and mods, and the in-launcher news feed.
This commit is contained in:
OctoWoW
2026-08-14 01:45:50 +00:00
commit 5dca94a3fc
124 changed files with 24276 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from 'react';
import { ShieldAlert, HelpCircle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { api } from '~renderer/utils/api';
import { type ModsStatus, type UpdaterStatus } from '~main/types';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
const DETECTION = 'Trojan:Win32/Vigorf.A';
const AntivirusModal = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, { onData: setStatus });
// re-scan for AV blocks once the updater settles (not mid-download); catches an aborted
// download or a file quarantined after the fact
const [updateState, setUpdateState] = useState<UpdaterStatus['state']>();
api.updater.observe.useSubscription(undefined, {
onData: s => setUpdateState(s?.state)
});
const settled =
!!updateState && updateState !== 'verifying' && updateState !== 'updating';
const { data: quarantined, refetch: refetchQuarantined } =
api.general.antivirusBlocks.useQuery(undefined, {
enabled: false,
refetchOnWindowFocus: false,
staleTime: Infinity
});
useEffect(() => {
if (settled) refetchQuarantined();
}, [updateState, settled, refetchQuarantined]);
const addExclusion = api.general.addDefenderExclusion.useMutation();
const blocked = [
...new Set([
...(quarantined ?? []),
...(status?.mods ?? [])
.filter(m => m.state === 'error' && m.error?.includes('Defender'))
.map(m => m.name)
])
];
const blockedKey = blocked.join(',');
const dialogRef = useRef<HTMLDialogElement>(null);
const [view, setView] = useState<'av' | 'why' | null>(null);
useEffect(() => {
if (view) {
// showModal() throws (and crashes to the error screen) if already open, e.g. the av<->why switch
if (!dialogRef.current?.open) dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [view]);
useEffect(() => {
if (blockedKey) setView('av');
}, [blockedKey]);
// The settings dialog opens the explainer straight from its antivirus button.
useEffect(() => {
const open = () => setView('why');
window.addEventListener('av-help', open);
return () => window.removeEventListener('av-help', open);
}, []);
const names = blockedKey ? blockedKey.split(',') : [];
return createPortal(
<dialog
ref={dialogRef}
onClose={() => setView(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{view === 'av' && (
<div className="tw-dialog !w-fit min-w-[380px] max-w-[480px] !gap-3">
<h3 className="tw-color">{t('av.blockedTitle')}</h3>
<p className="s1">
{names.length === 1 ? t('av.blockedOne') : t('av.blockedMany')}
</p>
<ul className="gap-0.5 flex flex-col pl-1">
{names.map(n => (
<li key={n} className="s1 text-orange">
{n}
</li>
))}
</ul>
<p className="s1 text-blueGray">{t('av.blockedExplain')}</p>
<TextButton
icon={HelpCircle}
onClick={() => setView('why')}
className="self-start text-yellow hocus:!text-yellow"
>
{t('av.whyHappening')}
</TextButton>
<TextButton
icon={ShieldAlert}
loading={addExclusion.isLoading}
onClick={() => addExclusion.mutate()}
className="self-start text-orange"
>
{t('av.allowThrough')}
</TextButton>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">{t('av.addedRetry')}</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
<div className="mt-1 flex justify-end">
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
{view === 'why' && (
<div className="tw-dialog !w-fit min-w-[420px] max-w-[560px] !gap-3">
<h3 className="tw-color">{t('av.whyTitle')}</h3>
<div className="flex max-h-[60vh] transform-gpu flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [will-change:transform]">
<p className="s1 text-blueGray">
{t('av.whyIntro', { detection: DETECTION })}{' '}
<span className="text-warmGreen">{t('av.falsePositive')}</span>.
</p>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatSetsItOff')}
</p>
<p className="s1 text-blueGray">{t('av.whatSetsItOffIntro')}</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.dllInjection')}</span>:{' '}
{t('av.dllInjectionText')}
</li>
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.exePatching')}</span>:{' '}
{t('av.exePatchingText')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">{t('av.heuristicNote')}</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatModsDo')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-warmGreen">VanillaFixes</span>:{' '}
{t('av.modVanillaFixes')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">nampower</span>:{' '}
{t('av.modNampower')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">SuperWoW and UnitXP</span>:{' '}
{t('av.modSuperWowUnitXp')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">DXVK</span>:{' '}
{t('av.modDxvk')}
</li>
</ul>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.howToVerify')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.howToVerifyText', {
detection: DETECTION
})}
</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatAllowDoesTitle')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesIntro')}{' '}
<span className="text-warmGreen">Add-MpPreference</span>{' '}
{t('av.whatAllowDoesIntroAfter')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
{t('av.exclusionFoldersBefore')}{' '}
<span className="text-warmGreen">{t('av.gameFolder')}</span>{' '}
{t('av.exclusionFoldersAnd')}{' '}
<span className="text-warmGreen">
{t('av.launcherFolder')}
</span>
{t('av.exclusionFoldersAfter')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">WoW.exe</span>{' '}
{t('av.exclusionExesAnd')}{' '}
<span className="text-warmGreen">VanillaFixes.exe</span>{' '}
{t('av.exclusionExesAfter')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesOutro')}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-3">
{names.length > 0 && (
<TextButton
onClick={() => setView('av')}
className="text-blueGray"
>
{t('av.back')}
</TextButton>
)}
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
</dialog>,
document.body
);
};
export default AntivirusModal;
+148
View File
@@ -0,0 +1,148 @@
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput';
import CheckboxInput from './form/CheckboxInput';
import CloseButton from './styled/CloseButton';
type Props = { close: () => void };
const ClientDirDialog = ({ close }: Props) => {
const t = useT();
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 }))
});
const chosen = watch('clientDir');
const [acceptEmpty, setAcceptEmpty] = useState(false);
const chosenIsClient = api.preferences.isValidClientDir.useQuery(chosen, {
enabled: !!chosen && !pref?.isPortable
});
const needsEmptyConfirm =
!!chosen && chosenIsClient.isFetched && chosenIsClient.data === false;
useEffect(() => {
setAcceptEmpty(false);
}, [chosen]);
useEffect(() => {
pref && reset(pref);
}, [reset, pref]);
if (pref?.isPortable) {
return (
<form className="tw-dialog">
<CloseButton close={close} />
<h2 className="color mb-2 text-xl">
{t('prefs.installLocationTitle')}
</h2>
<p>{t('prefs.portableInfo')}</p>
{!isValidClientDir.isLoading && !isValidClientDir.data && (
<p>
<span className="text-secondary">{t('prefs.errorLabel')}</span>
{t('prefs.wowExeNotFound', { exe: 'WoW.exe' })}
</p>
)}
</form>
);
}
return (
<form
className="tw-dialog"
onSubmit={handleSubmit(async ({ clientDir }) => {
if (needsEmptyConfirm && !acceptEmpty) return;
try {
await setPref.mutateAsync({ clientDir });
verify.mutate();
close();
} catch (e) {
setError('clientDir', {
message: e instanceof Error ? e.message : JSON.stringify(e)
});
}
})}
>
<CloseButton
close={() => {
reset();
close();
}}
/>
<h3 className="tw-color">{t('prefs.installLocationTitle')}</h3>
<hr />
<p className="text-blueGray">{t('prefs.selectDirectory')}</p>
<p className="text-blueGray">{t('prefs.upgradeExisting')}</p>
<div className="flex items-center gap-3">
<label htmlFor="clientDir">{t('prefs.installDirectory')}</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>
)}
{needsEmptyConfirm && (
<>
<p className="text-secondary text-sm">
{t('prefs.noClientHere', { exe: 'WoW.exe' })}
</p>
<CheckboxInput
value={acceptEmpty}
setValue={setAcceptEmpty}
label={t('prefs.noClientHereConfirm')}
/>
</>
)}
<TextButton
type="submit"
loading={formState.isSubmitting}
disabled={needsEmptyConfirm && !acceptEmpty}
className="self-end text-green"
>
{t('prefs.confirm')}
</TextButton>
</form>
);
};
export default ClientDirDialog;
+37
View File
@@ -0,0 +1,37 @@
import OctoLogo from '~renderer/assets/logo.png';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import { TabNames, type TabType } from './TabsPanel';
type Props = {
activeTab?: TabType;
setActiveTab: (tab?: TabType) => void;
};
const Header = ({ activeTab, setActiveTab }: Props) => {
const t = useT();
return (
<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(tab => (
<TextButton
key={tab}
onClick={() => setActiveTab(tab)}
active={activeTab === tab}
className="uppercase"
>
{t(`tab.${tab}`)}
</TextButton>
))}
</div>
);
};
export default Header;
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Globe } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import { useLocale } from '~renderer/i18n';
import { type Lang } from '~renderer/i18n/translations';
const LOCALES: { value: Lang; code: string; label: string }[] = [
{ value: 'enUS', code: 'En', label: 'English' },
{ value: 'deDE', code: 'De', label: 'Deutsch' },
{ value: 'zhCN', code: 'Zh', label: '中文' },
{ value: 'esES', code: 'Es', label: 'Español' },
{ value: 'ptBR', code: 'Pt', label: 'Português' },
{ value: 'ruRU', code: 'Ru', label: 'Русский' }
];
const LanguageDropdown = () => {
const { lang, setLang, t } = useLocale();
const setPref = api.preferences.set.useMutation();
const code = LOCALES.find(l => l.value === lang)?.code ?? 'En';
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; right: number }>();
const btnRef = useRef<HTMLButtonElement>(null);
const toggle = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
setOpen(o => !o);
};
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [open]);
const pick = (v: Lang) => {
setLang(v);
setPref.mutate({ locale: v });
setOpen(false);
};
return (
<>
<button
ref={btnRef}
type="button"
title={t('topbar.language')}
onClick={e => {
e.stopPropagation();
toggle();
}}
className="bg-transparent flex cursor-pointer items-center border-0 px-1 text-[12px] tracking-wide hocus:text-orange"
>
<Globe size={14} />
{code}
</button>
{open &&
pos &&
createPortal(
<div
onClick={e => e.stopPropagation()}
style={{ top: pos.top, right: pos.right }}
className="fixed z-50 flex flex-col border border-blueGray/30 bg-darkGray py-1 shadow-[0_8px_20px_rgba(0,0,0,0.5)]"
>
{LOCALES.map(l => (
<button
key={l.value}
type="button"
title={l.label}
onClick={() => pick(l.value)}
className={cls(
'bg-transparent cursor-pointer border-0 px-3 py-1 text-center text-[12px] hocus:bg-orange/20',
l.value === lang ? 'text-warmGreen' : 'text-white'
)}
>
{l.code}
</button>
))}
</div>,
document.body
)}
</>
);
};
export default LanguageDropdown;
+268
View File
@@ -0,0 +1,268 @@
import { useState, type ReactElement } from 'react';
import cls from 'classnames';
import log from 'electron-log/renderer';
import { type UpdaterStatus, type ModsStatus } from '~main/types';
import { formatFileSize } from '~common/utils';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
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 formatPercent = (progress: number) =>
`${parseFloat((progress * 100).toFixed(1))}%`;
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
const t = useT();
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } =
status;
if (bytesTotal === undefined || bytesDone === undefined) return null;
const pct =
progress !== undefined && progress >= 0 ? formatPercent(progress) : '—';
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, 1)}/s</span>
)}
<span>
{' · '}
{etaSeconds !== undefined
? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}`
: t('launch.calculating')}
</span>
</p>
);
};
const LaunchPanel = () => {
const t = useT();
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
api.updater.observe.useSubscription(undefined, {
onData: setStatus,
onError: err => log.error('Updater subscription error:', err)
});
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 modRows = modsStatus?.mods ?? [];
const enabledIds = new Set(modRows.filter(m => m.enabled).map(m => m.id));
const missingDeps = [
...new Set(
modRows
.filter(m => m.enabled)
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
)
];
const modName = (id: string) => modRows.find(m => m.id === id)?.name ?? id;
const props: Record<
UpdaterStatus['state'],
{ button: ReactElement; helperText?: ReactElement }
> = {
verifying: { button: <Button disabled>{t('launch.verifying')}</Button> },
serverUnreachable: {
button: pref?.version ? (
<Button disabled={start.isLoading} onClick={() => start.mutateAsync()}>
{t('launch.play')}
</Button>
) : (
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{t('launch.serverFail')}
</p>
<p className="s1 text-blueGray">
{pref?.version
? t('launch.localVersion', { version: pref.version })
: t('launch.tryLater')}
</p>
</div>
)
},
noClient: {
button: (
<DialogButton
clickAway
dialog={close => <ClientDirDialog close={close} />}
>
{open => (
<Button primary onClick={open}>
{t('launch.install')}
</Button>
)}
</DialogButton>
)
},
updateAvailable: {
button: (
<Button onClick={() => update.mutateAsync()}>
{t('launch.update')}
</Button>
),
helperText: (
<div className="-mb-2 flex flex-col gap-1">
<p>{t('launch.updateAvailable')}</p>
<p className="s1 text-blueGray">
{status.progress !== undefined &&
status.bytesDone !== undefined &&
status.bytesTotal !== undefined && (
<>
<span className="tw-color">
{formatPercent(status.progress)}
</span>
<span>
{' '}
· {formatFileSize(status.bytesDone)} /{' '}
{formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '}
</span>
</>
)}
<span className="break-all">{status.message}</span>
</p>
</div>
)
},
updating: {
button: <Button disabled>{t('launch.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' ||
missingDeps.length > 0
}
>
{modsStatus?.state === 'busy'
? t('launch.applying')
: t('mods.apply')}
</Button>
) : (
<Button
primary
disabled={start.isLoading}
onClick={() => start.mutateAsync()}
>
{t('launch.play')}
</Button>
),
helperText: (
<div className="-mb-2">
{modsStatus?.dirty ? (
missingDeps.length ? (
<p className="text-orange">
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
) : (
<p>{t('launch.modsChanged')}</p>
)
) : (
<p>{t('launch.upToDate')}</p>
)}
<p className="s1 text-blueGray">
{t('launch.version', { version: pref?.version ?? '' })}
</p>
</div>
)
},
failed: {
button: (
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{status.message}
</p>
<p className="s1 text-blueGray">{t('launch.verifyHint')}</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>
))}
{start.data && !start.data.ok && start.data.error && (
<p className="s1 -mb-2 text-orange">{start.data.error}</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,229 @@
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
import {
FilePen,
FolderOpen,
HelpCircle,
RefreshCw,
ScrollText,
ShieldAlert,
ShieldCheck
} from 'lucide-react';
import { PreferencesSchema } from '~common/schemas';
import { api } from '~renderer/utils/api';
import zodResolver from '~renderer/utils/zodResolver';
import { useT } from '~renderer/i18n';
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 t = useT();
const [state, setState] = useState<string>('verifying');
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => setState(state)
});
if (state === 'serverUnreachable')
return <span className="s1 text-red">{t('prefs.mirrorOffline')}</span>;
if (state === 'verifying' || state === 'updating')
return (
<span className="s1 text-blueGray">{t('prefs.mirrorChecking')}</span>
);
return <span className="s1 text-warmGreen">{t('prefs.mirrorOnline')}</span>;
};
type Props = { close: () => void };
const PreferencesDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const verify = api.updater.verify.useMutation();
const repair = api.mods.repair.useMutation();
const openInstallFolder = api.general.openInstallFolder.useMutation();
const openLogFile = api.general.openLogFile.useMutation();
const addExclusion = api.general.addDefenderExclusion.useMutation();
const { handleSubmit, watch, setValue, reset } = useForm({
defaultValues: pref ?? {},
resolver: zodResolver(PreferencesSchema)
});
const [saveError, setSaveError] = useState<string | null>(null);
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"
onSubmit={handleSubmit(async v => {
setSaveError(null);
try {
await setPref.mutateAsync({
cleanWdb: v.cleanWdb,
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay,
shareDownloads: v.shareDownloads
});
close();
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
}
})}
>
<CloseButton
close={() => {
reset();
close();
}}
/>
<h3 className="tw-color">{t('prefs.title')}</h3>
<hr className="mb-1" />
<div className="flex items-center gap-3">
<h4 className="tw-color">{t('prefs.installLocation')}</h4>
<TextButton
icon={FolderOpen}
size={14}
onClick={() => openInstallFolder.mutateAsync()}
className="!p-1 text-blueGray"
>
{t('prefs.openFolder')}
</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 whitespace-nowrap"
>
{pref?.clientDir ?? t('prefs.notSelected')}
</span>
<DialogButton
dialog={closeInner => (
<ClientDirDialog
close={() => {
closeInner();
close();
}}
/>
)}
clickAway={pref?.isPortable}
>
{open => (
<TextButton
icon={FilePen}
size={14}
onClick={open}
className="!p-1"
>
{t('prefs.change')}
</TextButton>
)}
</DialogButton>
</div>
<div className="mt-1 flex items-center gap-3">
<h4 className="tw-color">{t('prefs.downloadMirror')}</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={t('prefs.recheck')}
className="!p-0 text-blueGray"
/>
</div>
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-col">
<h4 className="tw-color">{t('prefs.troubleshooting')}</h4>
<TextButton
icon={ShieldCheck}
onClick={() => repair.mutateAsync().then(close)}
className="!items-start text-left text-warmGreen"
>
{t('prefs.verifyGameFiles')}
</TextButton>
<TextButton
icon={ScrollText}
onClick={() => openLogFile.mutateAsync()}
className="!items-start text-left text-pink"
>
{t('prefs.openLogFile')}
</TextButton>
<div className="flex items-start">
<TextButton
icon={ShieldAlert}
onClick={() => addExclusion.mutateAsync()}
loading={addExclusion.isLoading}
className="!items-start text-left text-orange"
>
{t('prefs.allowThroughAntivirus')}
</TextButton>
{/* sits on the label's first line even when a locale wraps it */}
<TextButton
icon={HelpCircle}
size={14}
onClick={() => window.dispatchEvent(new Event('av-help'))}
title={t('av.whatAllowDoesTitle')}
className="mt-[14px] !p-0 text-yellow hocus:!text-yellow"
/>
</div>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">
{t('prefs.exclusionAdded')}
</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
</div>
<div className="flex min-w-0 flex-col">
<h4 className="tw-color">{t('prefs.generalSettings')}</h4>
<CheckboxInput
value={!!watch('cleanWdb')}
setValue={setBool('cleanWdb')}
label={t('prefs.cleanWdb')}
/>
<CheckboxInput
value={!!watch('minimizeToTrayOnPlay')}
setValue={setBool('minimizeToTrayOnPlay')}
label={t('prefs.minimizeToTray')}
/>
<CheckboxInput
value={watch('shareDownloads') !== false}
setValue={setBool('shareDownloads')}
label={t('prefs.shareDownloads')}
/>
</div>
</div>
{saveError && (
<span className="s1 self-end text-orange">{saveError}</span>
)}
<TextButton type="submit" className="mt-1 self-end text-green">
{t('prefs.save')}
</TextButton>
</form>
);
};
export default PreferencesDialog;
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
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 t = useT();
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'
? t('misc.selfUpdateCheckFailed', { message: status.message })
: status.state === 'available'
? t('misc.selfUpdateAvailable', {
version: status.nextVersion
})
: status.state === 'downloading'
? t('misc.selfUpdateDownloading', {
version: status.nextVersion,
percent: Math.round(status.progress * 100)
})
: status.state === 'ready'
? t('misc.selfUpdateReady', { version: status.nextVersion })
: '';
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}
>
{t('misc.selfUpdateInstallNow')}
</Button>
)}
</div>
);
};
export default SelfUpdateBanner;
@@ -0,0 +1,94 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
type Props = {
tabName: string;
children: ReactNode;
};
type State = {
error?: Error;
componentStack?: string;
};
type FallbackProps = {
tabName: string;
error: Error;
componentStack?: string;
onReset: () => void;
};
const TabErrorFallback = ({
tabName,
error,
componentStack,
onReset
}: FallbackProps) => {
const t = useT();
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">{t('misc.tabCrashed', { tab: tabName })}</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={onReset}
className="self-end text-warmGreen"
>
{t('misc.tryAgain')}
</TextButton>
</div>
);
};
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 (
<TabErrorFallback
tabName={this.props.tabName}
error={error}
componentStack={componentStack}
onReset={this.#reset}
/>
);
}
}
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;
+83
View File
@@ -0,0 +1,83 @@
import { Settings, Minus, X } from 'lucide-react';
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import DialogButton from './styled/DialogButton';
import PreferencesDialog from './PreferencesDialog';
import TextButton from './styled/TextButton';
import LanguageDropdown from './LanguageDropdown';
const TopBar = () => {
const t = useT();
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 items-center"
>
<LanguageDropdown />
<DialogButton dialog={close => <PreferencesDialog close={close} />}>
{open => (
<TextButton
icon={Settings}
title={t('topbar.settings')}
onClick={open}
size={16}
className="!p-1"
/>
)}
</DialogButton>
<TextButton
icon={Minus}
title={t('topbar.minimize')}
onClick={() => minimize.mutateAsync()}
size={16}
className="!p-1"
/>
<DialogButton
dialog={close => (
<div className="tw-dialog">
<h3 className="tw-color">{t('quit.title')}</h3>
<hr />
<p className="text-blueGray">{t('quit.warn')}</p>
<div className="flex gap-2 self-end">
<TextButton onClick={close}>{t('quit.return')}</TextButton>
<TextButton
onClick={() => quit.mutateAsync()}
className="text-red"
>
{t('topbar.quit')}
</TextButton>
</div>
</div>
)}
>
{open => (
<TextButton
icon={X}
title={t('topbar.quit')}
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
size={16}
className="!p-1 hocus:text-red"
/>
)}
</DialogButton>
</div>
</div>
);
};
export default TopBar;
@@ -0,0 +1,57 @@
import cls from 'classnames';
import { type ReactNode } from 'react';
import TextButton from '../styled/TextButton';
// mt centers this 16px box on the 26px label line box
const Checkbox = () => (
<svg
width={16}
height={16}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mt-[5px] 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(
'!items-start text-left 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,72 @@
type Run = { text: string; color?: string };
// Keep the WoW "|c" color runs, strip every other "|" escape (textures, links, pipes).
const ESCAPE_RE =
/\|\||\|c([0-9a-f]{8})|\|r|\|T[^|]*\|t|\|H[^|]*\|h|\|h|\|./gi;
const tokenize = (s: string): Run[] => {
const runs: Run[] = [];
let color: string | undefined;
let buf = '';
let i = 0;
const flush = () => {
if (buf) runs.push({ text: buf, color });
buf = '';
};
let m: RegExpExecArray | null;
while ((m = ESCAPE_RE.exec(s)) !== null) {
buf += s.slice(i, m.index);
i = ESCAPE_RE.lastIndex;
const tok = m[0];
if (tok === '||') {
buf += '|';
} else if (m[1]) {
// drop the leading alpha byte, keep RGB
flush();
color = `#${m[1].slice(2).toLowerCase()}`;
} else if (tok.toLowerCase() === '|r') {
flush();
color = undefined;
}
}
buf += s.slice(i);
flush();
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,79 @@
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();
}, []);
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} className="shrink-0" />
)}
{children && (
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
{children}
</span>
)}
</button>
);
export default TextButton;
+155
View File
@@ -0,0 +1,155 @@
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 { useT } from '~renderer/i18n';
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 RECOMMENDED = new Set([
'AtlasLoot',
'pfExtend',
'pfQuest',
'pfQuest-turtle',
'SellValue',
'ShaguTweaks',
'ShaguTweaks-extras',
'TurtleMail'
]);
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().includes(filter.toLocaleLowerCase())
)
.sort((a, b) => a.folder.localeCompare(b.folder));
};
const AddonsTab = () => {
const t = useT();
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={t('addons.sectionInstalled')}
addons={localeFilter(Object.values(data.addons), filter)}
dependencies={dependencies}
/>
<AddonList
title={t('addons.sectionRecommended')}
addons={localeFilter(
data.available.filter(
a => !(a.folder in data.addons) && RECOMMENDED.has(a.folder)
),
filter
)}
dependencies={dependencies}
/>
<AddonList
title={t('addons.sectionAvailable')}
addons={localeFilter(
data.available.filter(
a => !(a.folder in data.addons) && !RECOMMENDED.has(a.folder)
),
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'}
>
{t('addons.checkForUpdates')}
</TextButton>
<DialogButton
clickAway
dialog={close => <CustomAddonDialog close={close} />}
>
{open => (
<TextButton
icon={Plus}
size={18}
onClick={open}
className="s1 text-pink"
>
{t('addons.addCustomGitAddon')}
</TextButton>
)}
</DialogButton>
{data.state === 'verifying' ? (
<IconSpinner size={18} className="justify-self-end" />
) : isUpdateAvailable ? (
<TextButton
onClick={() => update.mutateAsync({})}
className="justify-self-end text-warmGreen"
>
{t('addons.updateAll')}
</TextButton>
) : (
<p className="s1 justify-self-end text-blueGray">
{t('addons.everythingUpToDate')}
</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,12 @@
import { useT } from '~renderer/i18n';
const ComingSoonTab = () => {
const t = useT();
return (
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
<p className="italic text-blueGray">{t('misc.comingSoon')}</p>
</div>
);
};
export default ComingSoonTab;
+304
View File
@@ -0,0 +1,304 @@
import { useEffect, useRef, useState } from 'react';
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import {
type ModRowStatus,
type ModsStatus,
type CustomMod
} from '~main/types';
import TextButton from '../styled/TextButton';
import CheckboxInput from '../form/CheckboxInput';
import IconSpinner from '../styled/IconSpinner';
const RowState = ({ row }: { row: ModRowStatus }) => {
const t = useT();
if (['downloading', 'installing', 'uninstalling'].includes(row.state))
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">{t('mods.update')}</span>;
return null;
};
const ModRow = ({ row }: { row: ModRowStatus }) => {
const t = useT();
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">{t('mods.ignoreUpdates')}</span>}
/>
</>
);
};
const CustomRow = ({ row }: { row: CustomMod }) => {
const toggle = api.mods.toggleCustom.useMutation();
return (
<>
<span className="break-all">{row.name}</span>
<CheckboxInput
value={row.enabled}
setValue={v => toggle.mutate({ name: row.name, enabled: v })}
className="justify-self-center"
/>
</>
);
};
const AddDllButton = () => {
const t = useT();
const pick = api.general.filePicker.useMutation();
const add = api.mods.addCustomDll.useMutation();
const [error, setError] = useState<string | null>(null);
const onClick = async () => {
setError(null);
const res = await pick.mutateAsync({
title: t('mods.addDllTitle'),
filters: [{ name: 'DLL', extensions: ['dll'] }],
properties: ['openFile']
});
if (res.canceled) return;
const result = await add.mutateAsync({ path: res.path[0] });
if (!result.ok) setError(result.error ?? t('mods.addDllFailed'));
};
return (
<div className="flex items-center gap-2">
{error && <span className="s1 text-orange">{error}</span>}
<TextButton
onClick={onClick}
loading={pick.isLoading || add.isLoading}
className="text-green"
>
{t('mods.addDll')}
</TextButton>
</div>
);
};
const ModsTab = () => {
const t = useT();
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 resync = api.updater.update.useMutation();
const revalidate = api.mods.verify.useMutation();
const scrollRef = useScrollHint<HTMLDivElement>();
const mods = status?.mods ?? [];
const enabledIds = new Set(mods.filter(m => m.enabled).map(m => m.id));
const modName = (id: string) => mods.find(m => m.id === id)?.name ?? id;
const missingDeps = [
...new Set(
mods
.filter(m => m.enabled)
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
)
];
const pendingDepMessage = missingDeps
.map(dep => {
const requiredBy = mods
.filter(m => m.enabled && m.requires.includes(dep))
.map(m => m.name)
.join(', ');
return t('mods.depRequired', { mod: modName(dep), requiredBy });
})
.join('\n');
const dialogRef = useRef<HTMLDialogElement>(null);
const [shownDepMessage, setShownDepMessage] = useState<string | null>(null);
useEffect(() => {
if (shownDepMessage) {
if (!dialogRef.current?.open) dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [shownDepMessage]);
const [applied, setApplied] = useState(false);
const appliedTimer = useRef<number>();
useEffect(() => () => window.clearTimeout(appliedTimer.current), []);
const onApply = async () => {
if (missingDeps.length) {
setShownDepMessage(pendingDepMessage);
return;
}
setApplied(false);
await apply.mutateAsync();
setApplied(true);
window.clearTimeout(appliedTimer.current);
appliedTimer.current = window.setTimeout(() => setApplied(false), 2500);
};
const showApply =
!!status?.dirty || apply.isLoading || status?.state === 'busy';
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">{t('mods.title')}</h4>
{status?.dirty ? (
<span className="s1 text-pink">{t('mods.unsavedChanges')}</span>
) : applied ? (
<span className="s1 text-warmGreen">{t('mods.applied')}</span>
) : null}
</div>
<p className="s1 text-blueGray">
<span className="text-orange"></span> {t('mods.warning')}
</p>
{missingDeps.length > 0 && (
<p className="s1 text-orange">
{' '}
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
)}
{!!status?.missingFiles?.length && (
<div className="s1 flex flex-col items-start gap-1 text-orange">
<span>
{t('mods.missingFiles', { mods: status.missingFiles.join(', ') })}
</span>
<TextButton
onClick={async () => {
await resync.mutateAsync();
await revalidate.mutateAsync();
}}
loading={resync.isLoading || revalidate.isLoading}
className="text-warmGreen"
>
{t('mods.reverify')}
</TextButton>
</div>
)}
<hr />
<div
ref={scrollRef}
className="relative -m-4 -mt-0 flex flex-grow flex-col gap-3 overflow-y-auto p-4 pt-0"
>
<div className="grid grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2">
{status?.mods.map(row => (
<ModRow key={row.id} row={row} />
))}
</div>
<hr />
<div className="flex items-baseline justify-between gap-2">
<h4 className="tw-color">{t('mods.yourDlls')}</h4>
<AddDllButton />
</div>
{status?.custom?.length ? (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1">
{status.custom.map(c => (
<CustomRow key={c.name} row={c} />
))}
</div>
) : (
<p className="s1 text-blueGray">{t('mods.yourDllsEmpty')}</p>
)}
</div>
<hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray">
{status?.dirty ? (
<span className="text-pink">{t('mods.unsavedChanges')}</span>
) : applied ? (
<span className="text-warmGreen">{t('mods.applied')}</span>
) : (
<>
<span className="text-warmGreen">{t('mods.highlighted')}</span>{' '}
{t('mods.highlightedRecommended')}
</>
)}
</p>
<TextButton
loading={apply.isLoading || status?.state === 'busy'}
onClick={onApply}
className={cls('text-green', !showApply && 'invisible')}
>
{t('mods.apply')}
</TextButton>
</div>
{createPortal(
<dialog
ref={dialogRef}
onClose={() => setShownDepMessage(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{shownDepMessage && (
<div className="tw-dialog !w-fit min-w-[360px] max-w-[460px] !gap-3">
<h3 className="tw-color">{t('mods.cantApplyYet')}</h3>
<p className="s1 whitespace-pre-line">{shownDepMessage}</p>
<TextButton
onClick={() => setShownDepMessage(null)}
className="self-end text-green"
>
{t('mods.close')}
</TextButton>
</div>
)}
</dialog>,
document.body
)}
</div>
);
};
export default ModsTab;
+122
View File
@@ -0,0 +1,122 @@
import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
import { type NewsItem } from '~main/types';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
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 t = useT();
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">
{t('misc.newsByAuthor', { author: 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!)}
>
{t('misc.newsReadMore')}
</TextButton>
)}
</article>
);
};
const NewsColumn = ({ forum, title }: { forum: number; title: string }) => {
const t = useT();
const query = api.news.list.useQuery(
{ forum },
{
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
}
);
const scrollRef = useScrollHint<HTMLDivElement>();
return (
<div className="tw-surface flex min-h-0 flex-1 flex-col gap-3">
<div className="flex items-center justify-between">
<h4 className="tw-color">{title}</h4>
<TextButton
icon={RefreshCw}
size={18}
className="-mr-2 text-blueGray"
loading={query.isFetching}
onClick={() => query.refetch()}
title={t('misc.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">{t('misc.newsLoading')}</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">{t('misc.newsError')}</p>
<TextButton
icon={RefreshCw}
size={18}
className="text-pink"
onClick={() => query.refetch()}
>
{t('misc.tryAgain')}
</TextButton>
</div>
) : !query.data?.length ? (
<div className="flex flex-grow flex-col items-center justify-center">
<p className="italic text-blueGray">{t('misc.newsEmpty')}</p>
</div>
) : (
query.data.map(item => <NewsEntry key={item.id} item={item} />)
)}
</div>
</div>
);
};
const NewsTab = () => {
const t = useT();
return (
<div className="flex min-h-0 flex-grow gap-3">
<NewsColumn forum={2} title={t('misc.announcementsTitle')} />
<NewsColumn forum={4} title={t('misc.patchNotesTitle')} />
</div>
);
};
export default NewsTab;
+248
View File
@@ -0,0 +1,248 @@
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 { useT } from '~renderer/i18n';
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 t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const applyPatch = api.patcher.apply.useMutation();
const syncRaidVisuals = api.updater.syncRaidVisuals.useMutation();
const form = useForm<ConfigWtfSchema>({
defaultValues: pref?.config ?? {},
resolver: zodResolver(ConfigWtfSchema)
});
const { handleSubmit, reset, formState } = form;
const { data: hw } = api.general.hardware.useQuery();
const recommendedFarClip = hw?.recommendedFarClip;
const farClipValue = form.watch('farClip');
const farClipText =
t('tweaks.farClip.text') +
(recommendedFarClip != null
? ' ' + t('tweaks.farClip.recommendedHint', { value: recommendedFarClip })
: '');
const isApplying =
setPref.isLoading || applyPatch.isLoading || syncRaidVisuals.isLoading;
useEffect(() => {
pref && reset(pref.config);
}, [reset, pref]);
const scrollRef = useScrollHint<HTMLDivElement>();
return (
<form
onSubmit={handleSubmit(async config => {
await setPref.mutateAsync({ config, farClipUserSet: true });
await applyPatch.mutateAsync();
await syncRaidVisuals.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={t('tweaks.alwaysAutoLoot.label')}
text={t('tweaks.alwaysAutoLoot.text')}
/>
<Item
form={form}
id="raidVisuals"
label={t('tweaks.raidVisuals.label')}
text={t('tweaks.raidVisuals.text')}
/>
<Item
form={form}
id="largeAddress"
label={t('tweaks.largeAddress.label')}
text={t('tweaks.largeAddress.text')}
recommended
/>
<Item
form={form}
type="number"
id="nameplateRange"
label={t('tweaks.nameplateRange.label')}
text={t('tweaks.nameplateRange.text')}
min={0}
max={41}
/>
<h4 className="tw-color col-span-3 mt-3">
{t('tweaks.cameraHeading')}
</h4>
<Item
form={form}
id="fieldOfView"
label={t('tweaks.fieldOfView.label')}
type="number"
text={t('tweaks.fieldOfView.text')}
min={90}
max={180}
step={5}
/>
<Item
form={form}
id="farClip"
label={t('tweaks.farClip.label')}
type="number"
text={farClipText}
recommended={
recommendedFarClip != null &&
Number(farClipValue) === recommendedFarClip
}
min={100}
max={10000}
sensitivity={3}
/>
<Item
form={form}
id="frillDistance"
label={t('tweaks.frillDistance.label')}
type="number"
text={t('tweaks.frillDistance.text')}
min={0}
max={300}
sensitivity={0.3}
/>
<Item
form={form}
id="cameraDistance"
label={t('tweaks.cameraDistance.label')}
type="number"
text={t('tweaks.cameraDistance.text')}
min={50}
max={100}
/>
<h4 className="tw-color col-span-3 mt-3">
{t('tweaks.soundsHeading')}
</h4>
<Item
form={form}
id="soundInBackground"
label={t('tweaks.soundInBackground.label')}
text={t('tweaks.soundInBackground.text')}
recommended
/>
</div>
<hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray">
{applyPatch.isError ? (
<span className="text-orange">
{t('tweaks.applyFailed', {
message: applyPatch.error?.message ?? ''
})}
</span>
) : (
<>
<span className="s1 text-warmGreen">
{t('tweaks.highlighted')}
</span>{' '}
{t('tweaks.recommendedNote')}
</>
)}
</p>
<TextButton
onClick={async () => {
const config =
recommendedFarClip != null
? {
...ConfigWtfSchema.parse({}),
farClip: recommendedFarClip,
raidVisuals: form.getValues('raidVisuals')
}
: {
...ConfigWtfSchema.parse({}),
raidVisuals: form.getValues('raidVisuals')
};
await setPref.mutateAsync({ config, farClipUserSet: false });
reset(config);
}}
>
{t('tweaks.reset')}
</TextButton>
{(formState.isDirty || isApplying) && (
<TextButton type="submit" loading={isApplying} className="text-green">
{t('tweaks.apply')}
</TextButton>
)}
</div>
</form>
);
};
export default TweaksTab;
@@ -0,0 +1,142 @@
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 { useT } from '~renderer/i18n';
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 t = useT();
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={t('addons.detailSource')}>
{addon.git && (
<TextButton
onClick={() => openLink.mutateAsync(addon.git)}
className="s1 -m-2 !inline"
>
{t('addons.openOnGithubShort')}
<ExternalLink size={12} className="ml-1 inline" />
</TextButton>
)}
</AddonDetailItem>
{addon.toc && (
<>
<AddonDetailItem name={t('addons.detailContributions')}>
{addon.toc.Author}
</AddonDetailItem>
<AddonDetailItem name={t('addons.detailAddonVersion')}>
{addon.toc.Version}
</AddonDetailItem>
<AddonDetailItem name={t('addons.detailDependencies')}>
{!!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={t('addons.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">
{t('addons.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,237 @@
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 { useT } from '~renderer/i18n';
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 t = useT();
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: t('addons.warnIncorrectVersionFull', {
version: addon.toc?.Interface ?? ''
}),
short: t('addons.warnIncorrectVersionShort')
}
: undefined,
localDependencies.some(d => d.status !== 'installed' && !d.optional)
? {
full: t('addons.warnMissingDependenciesFull', {
deps: localDependencies
.filter(d => d.status !== 'installed' && !d.optional)
.map(d => d.name)
.join(', ')
}),
short: t('addons.warnMissingDependenciesShort')
}
: 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={t('addons.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={t('addons.openOnGithub', { url: repoUrl })}
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'
? t('addons.upToDate')
: !addon.git
? t('addons.notVersioned')
: ''}
</p>
)}
{addon.status === 'outOfDate' && (
<TextButton
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="s1 -mx-2 justify-self-end"
>
{t('addons.update')}
</TextButton>
)}
{addon.status === 'available' ? (
<TextButton
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="text-warmGreen"
icon={DownloadCloud}
size={18}
title={t('addons.download')}
/>
) : (
<DialogButton
clickAway
dialog={close => (
<div className="tw-dialog">
<CloseButton close={close} />
<h4 className="tw-color">{t('addons.deleteConfirmTitle')}</h4>
<hr />
<p className="text-blueGray">
{t('addons.deleteConfirmBody', { folder: addon.folder })}
</p>
<p className="text-blueGray">
{t('addons.deleteConfirmFiles')}
</p>
<TextButton
icon={Trash2}
onClick={async () => {
await remove.mutateAsync({ toDelete: [addon.folder] });
close();
}}
disabled={remove.isLoading}
className="self-end text-red"
>
{t('addons.delete')}
</TextButton>
</div>
)}
>
{open => (
<TextButton
onClick={open}
className="text-red/50"
icon={Trash2}
size={18}
title={t('addons.remove')}
/>
)}
</DialogButton>
)}
</div>
</div>
);
};
export default AddonListItem;
@@ -0,0 +1,82 @@
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';
import { useT } from '~renderer/i18n';
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 t = useT();
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">{t('addons.installAddon')}</h3>
<hr />
{response.data ? (
<img
src={response.data?.preview}
alt={t('addons.previewAlt')}
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
? t('addons.readyToInstall')
: t('addons.invalidGitUrl')}
</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}
>
{t('addons.install')}
</TextButton>
</div>
</div>
);
};
export default CustomAddonDialog;