forked from akanyan/STARTLINER
feat: internationalization
This commit is contained in:
@ -221,15 +221,12 @@ listen<DownloadingStatus>('download-progress', (event) => {
|
||||
>
|
||||
<div class="fixed w-full flex z-100">
|
||||
<TabList class="grow" :show-navigators="false">
|
||||
<Tab value="users"><div class="pi pi-users"></div></Tab>
|
||||
<Tab value="users"><div class="pi pi-home"></div></Tab>
|
||||
<Tab :disabled="isProfileDisabled" value="loc"
|
||||
><div class="pi pi-box"></div
|
||||
></Tab>
|
||||
<Tab
|
||||
v-if="
|
||||
prf.current?.meta.game === 'chunithm' &&
|
||||
prf.current.data.sgt.target.length > 0
|
||||
"
|
||||
v-if="(prf.current?.data.sgt.target.length ?? 0) > 0"
|
||||
value="patches"
|
||||
><div class="pi pi-ticket"></div
|
||||
></Tab>
|
||||
|
@ -12,7 +12,7 @@ invoke('get_changelog').then((s) => (changelog.value = s as string));
|
||||
|
||||
<template>
|
||||
<h1>About</h1>
|
||||
STARTLINER is a simple launcher, configuration tool and mod manager for
|
||||
STARTLINER is a launcher, configuration tool and mod manager for
|
||||
O.N.G.E.K.I. and CHUNITHM.
|
||||
<h1>Changelog</h1>
|
||||
<ScrollPanel style="height: 200px">
|
||||
|
@ -8,6 +8,9 @@ import { invoke } from '../invoke';
|
||||
import { usePkgStore, usePrfStore } from '../stores';
|
||||
import { Package } from '../types';
|
||||
import { pkgKey } from '../util';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
search: String,
|
||||
@ -55,7 +58,7 @@ const missing = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fieldset legend="Missing" v-if="(missing?.length ?? 0) > 0">
|
||||
<Fieldset :legend="t('store.missing')" v-if="(missing?.length ?? 0) > 0">
|
||||
<div class="flex items-center" v-for="p in missing">
|
||||
<ModTitlecard
|
||||
show-namespace
|
||||
|
@ -10,6 +10,9 @@ import { invoke } from '../invoke';
|
||||
import { usePkgStore, usePrfStore } from '../stores';
|
||||
import { Feature, Package } from '../types';
|
||||
import { hasFeature } from '../util';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
const pkgs = usePkgStore();
|
||||
@ -38,12 +41,7 @@ if (unsupported.value === true && model.value === true) {
|
||||
<div class="flex items-center">
|
||||
<ModTitlecard show-version show-icon show-description :pkg="pkg" />
|
||||
<UpdateButton :pkg="pkg" />
|
||||
<span
|
||||
v-tooltip="
|
||||
unsupported &&
|
||||
'This package is currently incompatible with STARTLINER.'
|
||||
"
|
||||
>
|
||||
<span v-tooltip="unsupported && t('store.incompatible')">
|
||||
<ToggleSwitch
|
||||
v-if="hasFeature(pkg, Feature.Mod) || unsupported === true"
|
||||
class="scale-[1.33] shrink-0"
|
||||
|
@ -8,6 +8,9 @@ import ModStoreEntry from './ModStoreEntry.vue';
|
||||
import { invoke } from '../invoke';
|
||||
import { usePkgStore, usePrfStore } from '../stores';
|
||||
import { pkgKey } from '../util';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const pkgs = usePkgStore();
|
||||
const prf = usePrfStore();
|
||||
@ -60,7 +63,7 @@ const getRecommendedTooltip = () => {
|
||||
return 'segatools-mu3hook';
|
||||
}
|
||||
if (prf.current!.meta.game === 'chunithm') {
|
||||
return 'segatools-chusanhook and mempatcher';
|
||||
return 'segatools-chusanhook + mempatcher';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@ -80,15 +83,17 @@ const installRecommended = () => {
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<div class="grow">Show installed</div>
|
||||
<div class="grow">{{ t('store.installed') }}</div>
|
||||
<ToggleSwitch v-model="pkgs.showInstalled" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-amber-400 grow">Show deprecated</div>
|
||||
<div class="text-amber-400 grow">
|
||||
{{ t('store.deprecated') }}
|
||||
</div>
|
||||
<ToggleSwitch v-model="pkgs.showDeprecated" />
|
||||
</div>
|
||||
<!-- <div class="flex gap-2">
|
||||
<div class="text-red-400 grow">Show NSFW</div>
|
||||
<div class="text-red-400 grow">{{ t('store.nsfw') }}</div>
|
||||
<ToggleSwitch v-model="pkgs.showNSFW" />
|
||||
</div> -->
|
||||
</div>
|
||||
@ -114,7 +119,7 @@ const installRecommended = () => {
|
||||
<Divider />
|
||||
<Button
|
||||
v-if="shouldShowRecommended"
|
||||
label="Install recommended packages"
|
||||
:label="t('store.installRecommended')"
|
||||
v-tooltip="getRecommendedTooltip"
|
||||
icon="pi pi-plus"
|
||||
class="mb-3"
|
||||
|
@ -14,6 +14,9 @@ import NetworkOptions from './options/Network.vue';
|
||||
import SegatoolsOptions from './options/Segatools.vue';
|
||||
import StartlinerOptions from './options/Startliner.vue';
|
||||
import { usePrfStore } from '../stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
|
||||
@ -60,7 +63,7 @@ prf.reload();
|
||||
title="Extensions"
|
||||
v-if="prf.current!.meta.game === 'chunithm'"
|
||||
>
|
||||
<OptionRow title="Saekawa config">
|
||||
<OptionRow :title="t('cfg.extensions.saekawa')">
|
||||
<FileEditor
|
||||
filename="saekawa.toml"
|
||||
promptname="saekawa config file"
|
||||
@ -68,31 +71,31 @@ prf.reload();
|
||||
/> </OptionRow
|
||||
></OptionCategory>
|
||||
<OptionCategory
|
||||
title="Extensions"
|
||||
:title="t('cfg.extensions.title')"
|
||||
v-if="prf.current!.meta.game === 'ongeki'"
|
||||
>
|
||||
<OptionRow title="Inohara config">
|
||||
<OptionRow :title="t('cfg.extensions.inohara')">
|
||||
<FileEditor
|
||||
filename="inohara.cfg"
|
||||
promptname="inohara config file"
|
||||
extension="cfg"
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow title="BepInEx console">
|
||||
<OptionRow :title="t('cfg.extensions.bepInExConsole')">
|
||||
<!-- @vue-expect-error -->
|
||||
<ToggleSwitch v-model="prf.current!.data.bepinex.console" />
|
||||
</OptionRow>
|
||||
|
||||
<OptionRow
|
||||
title="Audio mode"
|
||||
tooltip="Exclusive 2-channel mode requires 7EVENDAYSHOLIDAYS-ExclusiveAudio"
|
||||
:title="t('cfg.extensions.audioMode')"
|
||||
:tooltip="t('cfg.extensions.audioTooltip')"
|
||||
>
|
||||
<SelectButton
|
||||
v-model="prf.current!.data.mu3_ini!.audio"
|
||||
:options="[
|
||||
{ title: 'Shared', value: 'Shared' },
|
||||
{ title: 'Exclusive 6-channel', value: 'Excl6Ch' },
|
||||
{ title: 'Exclusive 2-channel', value: 'Excl2Ch' },
|
||||
{ title: t('cfg.extensions.audioShared'), value: 'Shared' },
|
||||
{ title: t('cfg.extensions.audio6Ch'), value: 'Excl6Ch' },
|
||||
{ title: t('cfg.extensions.audio2Ch'), value: 'Excl2Ch' },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
@ -100,7 +103,7 @@ prf.reload();
|
||||
/></OptionRow>
|
||||
|
||||
<OptionRow
|
||||
title="Sample rate"
|
||||
:title="t('cfg.extensions.sampleRate')"
|
||||
v-if="
|
||||
prf.current?.data.mods.includes(
|
||||
'7EVENDAYSHOLIDAYS-ExclusiveAudio'
|
||||
@ -126,8 +129,8 @@ prf.reload();
|
||||
prf.current?.data.mods.includes('7EVENDAYSHOLIDAYS-Blacklist')
|
||||
"
|
||||
class="number-input"
|
||||
title="Song ID Blacklist"
|
||||
tooltip="Scores on charts within this ID range will not be saved nor uploaded"
|
||||
:title="t('cfg.extensions.blacklist')"
|
||||
:tooltip="t('cfg.extensions.blacklistTooltip')"
|
||||
><InputNumber
|
||||
class="shrink"
|
||||
size="small"
|
||||
@ -165,8 +168,8 @@ prf.reload();
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Unlock Bonus Tracks"
|
||||
tooltip="Disabling this option can help declutter the song list"
|
||||
:title="t('cfg.extensions.bonusTracks')"
|
||||
:tooltip="t('cfg.extensions.bonusTracksTooltip')"
|
||||
v-if="
|
||||
prf.current?.data.mods.includes(
|
||||
'7EVENDAYSHOLIDAYS-UnlockAllMusic'
|
||||
|
@ -6,6 +6,9 @@ import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import OptionRow from './OptionRow.vue';
|
||||
import { usePrfStore } from '../stores';
|
||||
import { Patch } from '../types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
|
||||
@ -45,17 +48,20 @@ const hexModel = computed({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Doesn't need to be reactive
|
||||
const nameKey = `patch.${props.patch?.id}`;
|
||||
let name = t(nameKey);
|
||||
if (name === nameKey) {
|
||||
name = props.patch?.name ?? 'No name';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OptionRow
|
||||
:title="patch?.name"
|
||||
:tooltip="patch?.tooltip"
|
||||
:greytext="patch?.id"
|
||||
>
|
||||
<OptionRow :title="name" :tooltip="patch?.tooltip" :greytext="patch?.id">
|
||||
<ToggleSwitch
|
||||
v-if="patch?.type === undefined"
|
||||
:model-value="prf.current!.data.patches[patch!.id!] !== undefined"
|
||||
:model-value="prf.current!.data.patches?.[patch!.id!] !== undefined"
|
||||
@update:model-value="(v: boolean) => toggleUnary(patch!.id!, v)"
|
||||
/>
|
||||
<InputNumber
|
||||
|
@ -6,6 +6,9 @@ import PatchEntry from './PatchEntry.vue';
|
||||
import { invoke } from '../invoke';
|
||||
import { usePrfStore } from '../stores';
|
||||
import { Patch } from '../types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
|
||||
@ -30,18 +33,21 @@ invoke('list_patches', { target: prf.current!.data.sgt.target }).then(
|
||||
})) as Patch[];
|
||||
})();
|
||||
|
||||
const errorMessage =
|
||||
"No compatible patches found. Make sure you're using unpacked and unpatched files.";
|
||||
const errorMessage = t('patch.noneFound');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OptionCategory title="chusanApp.exe" always-found>
|
||||
<OptionCategory
|
||||
v-if="prf.current?.meta.game === 'chunithm'"
|
||||
title="chusanApp.exe"
|
||||
always-found
|
||||
>
|
||||
<PatchEntry
|
||||
v-if="gamePatches !== null"
|
||||
v-for="p in gamePatches"
|
||||
:patch="p"
|
||||
/>
|
||||
<div v-if="gamePatches === null">Loading...</div>
|
||||
<div v-if="gamePatches === null">{{ t('patch.loading') }}</div>
|
||||
<div v-if="gamePatches !== null && gamePatches.length === 0">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
@ -2,12 +2,17 @@
|
||||
import { Ref, ref } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Select from 'primevue/select';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import * as path from '@tauri-apps/api/path';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import ProfileListEntry from './ProfileListEntry.vue';
|
||||
import { invoke } from '../invoke';
|
||||
import { useClientStore, useGeneralStore, usePrfStore } from '../stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
const client = useClientStore();
|
||||
@ -61,7 +66,7 @@ const importPick = async () => {
|
||||
directory: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'STARTLINER template',
|
||||
name: t('profile.template'),
|
||||
extensions: ['zip'],
|
||||
},
|
||||
],
|
||||
@ -117,42 +122,94 @@ const importPick = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<div v-if="prf.list.length === 0">
|
||||
Welcome to STARTLINER! Start by creating a profile.
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
|
||||
<Button
|
||||
label="O.N.G.E.K.I. profile"
|
||||
icon="pi pi-file-plus"
|
||||
class="ongeki-button profile-button"
|
||||
@click="() => prf.create('ongeki')"
|
||||
/>
|
||||
<Button
|
||||
label="CHUNITHM profile"
|
||||
icon="pi pi-file-plus"
|
||||
class="chunithm-button profile-button"
|
||||
@click="() => prf.create('chunithm')"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
|
||||
<Button
|
||||
label="Import template"
|
||||
icon="pi pi-file-import"
|
||||
class="import-button profile-button"
|
||||
@click="() => importPick()"
|
||||
/>
|
||||
<Button
|
||||
:disabled="prf.current === null"
|
||||
label="Export template"
|
||||
icon="pi pi-file-export"
|
||||
class="profile-button"
|
||||
@click="() => openExportDialog()"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-12 flex flex-col flex-wrap align-middle gap-4">
|
||||
<div v-for="p in prf.list">
|
||||
<ProfileListEntry :p="p" />
|
||||
<div style="float: left">
|
||||
<div v-if="prf.list.length === 0">
|
||||
{{ t('profile.welcome') }}
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
|
||||
<Button
|
||||
:label="t('profile.create', { game: t('game.ongeki') })"
|
||||
icon="pi pi-file-plus"
|
||||
class="ongeki-button profile-button"
|
||||
@click="() => prf.create('ongeki')"
|
||||
/>
|
||||
<Button
|
||||
:label="t('profile.create', { game: t('game.chunithm') })"
|
||||
icon="pi pi-file-plus"
|
||||
class="chunithm-button profile-button"
|
||||
@click="() => prf.create('chunithm')"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
|
||||
<Button
|
||||
label="Import template"
|
||||
icon="pi pi-file-import"
|
||||
class="import-button profile-button"
|
||||
@click="() => importPick()"
|
||||
/>
|
||||
<Button
|
||||
:disabled="prf.current === null"
|
||||
label="Export template"
|
||||
icon="pi pi-file-export"
|
||||
class="profile-button"
|
||||
@click="() => openExportDialog()"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-12 flex flex-col flex-wrap align-middle gap-4">
|
||||
<div v-for="p in prf.list">
|
||||
<ProfileListEntry :p="p" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="float: right" class="mr-5 mt-3 flex flex-col gap-4 items-end">
|
||||
<div>
|
||||
<div class="pi pi-language mr-2"></div>
|
||||
<Select
|
||||
:model-value="client.locale"
|
||||
@update:model-value="async (v) => await client.setLocale(v)"
|
||||
style="width: 200px"
|
||||
:options="[
|
||||
{ title: 'English', value: 'en' },
|
||||
// { title: '日本語', value: 'ja' },
|
||||
]"
|
||||
size="small"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
></Select>
|
||||
</div>
|
||||
<SelectButton
|
||||
style="height: 50px"
|
||||
v-model="client.scaleModel"
|
||||
:options="[
|
||||
{ title: 'S', size: '0.8em', value: 's' },
|
||||
{ title: 'M', size: '1.0em', value: 'm' },
|
||||
{ title: 'L', size: '1.2em', value: 'l' },
|
||||
{ title: 'XL', size: '1.4em', value: 'xl' },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
><template #option="slotProps">
|
||||
<div :style="{ fontSize: slotProps.option.size }">
|
||||
{{ slotProps.option.title }}
|
||||
</div>
|
||||
</template></SelectButton
|
||||
>
|
||||
<SelectButton
|
||||
style="height: 50px"
|
||||
:model-value="client.theme"
|
||||
@update:model-value="(v) => client.setTheme(v)"
|
||||
:options="[
|
||||
{ title: 'System', value: 'system', icon: 'pi pi-home' },
|
||||
{ title: 'Light', value: 'light', icon: 'pi pi-sun' },
|
||||
{ title: 'Dark', value: 'dark', icon: 'pi pi-moon' },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
><template #option="slotProps">
|
||||
<div :class="slotProps.option.icon"></div> </template
|
||||
></SelectButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -7,6 +7,9 @@ import * as path from '@tauri-apps/api/path';
|
||||
import { invoke } from '../invoke';
|
||||
import { useGeneralStore, usePrfStore } from '../stores';
|
||||
import { ProfileMeta } from '../types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const general = useGeneralStore();
|
||||
const prf = usePrfStore();
|
||||
@ -60,8 +63,10 @@ const deleteProfile = async () => {
|
||||
|
||||
const promptDeleteProfile = async () => {
|
||||
confirmDialog.require({
|
||||
message: `Are you sure you want to delete ${props.p?.game}-${props.p?.name}?`,
|
||||
header: 'Delete profile',
|
||||
message: t('profile.reallyDelete', {
|
||||
profile: `${props.p?.game}-${props.p?.name}`,
|
||||
}),
|
||||
header: t('profile.delete'),
|
||||
accept: deleteProfile,
|
||||
});
|
||||
};
|
||||
|
@ -8,6 +8,9 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import Onboarding from './Onboarding.vue';
|
||||
import { invoke } from '../invoke';
|
||||
import { useClientStore, usePrfStore } from '../stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
const client = useClientStore();
|
||||
@ -24,22 +27,22 @@ const startline = async (force: boolean, refresh: boolean) => {
|
||||
if (start_check.length > 0) {
|
||||
const message = start_check.map((o) => {
|
||||
if ('MissingRemotePackage' in o) {
|
||||
return `Package missing: ${o.MissingRemotePackage}`;
|
||||
return `${t('start.error.package')}: ${o.MissingRemotePackage}`;
|
||||
} else if ('MissingLocalPackage' in o) {
|
||||
return `Package missing: ${o.MissingLocalPackage}`;
|
||||
return `${t('start.error.package')}: ${o.MissingLocalPackage}`;
|
||||
} else if ('MissingDependency' in o) {
|
||||
return `Dependency missing: ${(o.MissingDependency as string[]).join(' ')}`;
|
||||
return `${t('start.error.dependency')}: ${(o.MissingDependency as string[]).join(' ')}`;
|
||||
} else if ('MissingTool' in o) {
|
||||
return `Tool missing: ${o.MissingTool}`;
|
||||
return `${t('start.error.tool')}: ${o.MissingTool}`;
|
||||
} else {
|
||||
return 'Unknown error';
|
||||
return t('start.error.unknown');
|
||||
}
|
||||
});
|
||||
confirmDialog.require({
|
||||
message: message.join('\n'),
|
||||
header: 'Start check failed',
|
||||
acceptLabel: 'Run anyway',
|
||||
rejectLabel: 'Cancel',
|
||||
header: t('start.failed'),
|
||||
acceptLabel: t('start.accept'),
|
||||
rejectLabel: t('cancel'),
|
||||
accept: () => {
|
||||
startline(true, refresh);
|
||||
},
|
||||
@ -62,16 +65,16 @@ const kill = async () => {
|
||||
|
||||
const disabledTooltip = computed(() => {
|
||||
if (prf.current?.data.sgt.target.length === 0) {
|
||||
return 'The game path must be specified';
|
||||
return t('start.tooltip.game');
|
||||
}
|
||||
if (prf.current?.data.sgt.amfs.length === 0) {
|
||||
return 'The amfs path must be specified';
|
||||
return t('start.tooltip.amfs');
|
||||
}
|
||||
if (
|
||||
prf.current?.data.sgt.hook === null ||
|
||||
prf.current?.data.sgt.hook === undefined
|
||||
) {
|
||||
return 'A segatools hook package is necessary';
|
||||
return t('start.tooltip.segatools');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@ -96,32 +99,50 @@ const createShortcut = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Refresh and start',
|
||||
icon: 'pi pi-sync',
|
||||
tooltip: 'test',
|
||||
command: async () => await startline(false, true),
|
||||
},
|
||||
{
|
||||
label: 'Start unchecked',
|
||||
icon: 'pi pi-exclamation-circle',
|
||||
command: async () => await startline(true, false),
|
||||
},
|
||||
{
|
||||
label: 'Create desktop shortcut',
|
||||
icon: 'pi pi-link',
|
||||
command: createShortcut,
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
icon: 'pi pi-question-circle',
|
||||
command: () => {
|
||||
onboardingFirstTime.value = false;
|
||||
onboardingVisible.value = true;
|
||||
const menuItems = computed(() => {
|
||||
const base = [
|
||||
{
|
||||
label: t('start.button.unchecked'),
|
||||
icon: 'pi pi-exclamation-circle',
|
||||
command: async () => await startline(true, false),
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
label: t('start.button.shortcut'),
|
||||
icon: 'pi pi-link',
|
||||
command: createShortcut,
|
||||
},
|
||||
{
|
||||
label: t('start.button.help'),
|
||||
icon: 'pi pi-question-circle',
|
||||
command: () => {
|
||||
onboardingFirstTime.value = false;
|
||||
onboardingVisible.value = true;
|
||||
},
|
||||
},
|
||||
];
|
||||
if (prf.current === null) {
|
||||
return [];
|
||||
}
|
||||
if (prf.current.meta.game === 'chunithm') {
|
||||
return base;
|
||||
}
|
||||
if (prf.current.meta.game === 'ongeki') {
|
||||
return [
|
||||
{
|
||||
label: t('start.button.refresh'),
|
||||
icon: 'pi pi-sync',
|
||||
command: async () => await startline(false, true),
|
||||
},
|
||||
...base,
|
||||
{
|
||||
label: t('start.button.cache'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {},
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const menu = ref();
|
||||
|
||||
const showContextMenu = (event: Event) => {
|
||||
@ -177,7 +198,7 @@ const tryStart = () => {
|
||||
v-else-if="startStatus === 'preparing'"
|
||||
disabled
|
||||
icon="pi pi-spin pi-spinner"
|
||||
label="START"
|
||||
:label="t('start.button.start')"
|
||||
aria-label="start"
|
||||
size="small"
|
||||
class="m-2.5"
|
||||
@ -186,7 +207,7 @@ const tryStart = () => {
|
||||
v-else
|
||||
:disabled="false"
|
||||
icon="pi pi-ban"
|
||||
label="STOP"
|
||||
:label="t('start.button.stop')"
|
||||
aria-label="stop"
|
||||
size="small"
|
||||
class="m-2.5"
|
||||
|
@ -12,6 +12,9 @@ import { invoke } from '../../invoke';
|
||||
import { usePkgStore, usePrfStore } from '../../stores';
|
||||
import { Feature } from '../../types';
|
||||
import { hasFeature, pkgKey } from '../../util';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const pkgs = usePkgStore();
|
||||
const prf = usePrfStore();
|
||||
@ -60,14 +63,18 @@ load();
|
||||
<template>
|
||||
<OptionCategory title="Aime">
|
||||
<OptionRow
|
||||
title="Aime type"
|
||||
tooltip="Additional Aime plugins can be downloaded from the package store."
|
||||
:title="t('cfg.aime.type')"
|
||||
:tooltip="
|
||||
t('cfg.segatools.installTooltip', {
|
||||
thing: t('cfg.aime.modules'),
|
||||
})
|
||||
"
|
||||
>
|
||||
<Select
|
||||
v-model="prf.current!.data.sgt.aime"
|
||||
:options="[
|
||||
{ title: 'hardware', value: 'Disabled' },
|
||||
{ title: 'segatools built-in emulation', value: 'BuiltIn' },
|
||||
{ title: t('cfg.hardware'), value: 'Disabled' },
|
||||
{ title: t('cfg.segatools.builtIn'), value: 'BuiltIn' },
|
||||
...pkgs.byFeature(Feature.Aime).map((p) => {
|
||||
return {
|
||||
title: pkgKey(p),
|
||||
@ -83,8 +90,8 @@ load();
|
||||
></Select>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Aime code"
|
||||
tooltip="Only applicable with the segatools built-in emulation or with compatible third-party packages"
|
||||
:title="t('cfg.aime.code')"
|
||||
:tooltip="t('cfg.aime.codeTooltip')"
|
||||
v-if="prf.current!.data.sgt.aime !== 'Disabled'"
|
||||
>
|
||||
<InputText
|
||||
@ -97,7 +104,7 @@ load();
|
||||
/>
|
||||
</OptionRow>
|
||||
<div v-if="prf.current!.data.sgt.aime?.hasOwnProperty('AMNet')">
|
||||
<OptionRow title="Server name">
|
||||
<OptionRow :title="t('cfg.aime.serverName')">
|
||||
<InputText
|
||||
class="shrink"
|
||||
size="small"
|
||||
@ -106,7 +113,7 @@ load();
|
||||
v-model="prf.current!.data.sgt.amnet.name"
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow title="Server address">
|
||||
<OptionRow :title="t('cfg.network.address')">
|
||||
<InputText
|
||||
class="shrink"
|
||||
size="small"
|
||||
@ -116,22 +123,21 @@ load();
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Use AiMeDB for physical cards"
|
||||
tooltip="Whether physical cards should use AiMeDB to retrieve access codes. If the game is using a hosted network, enable this option to load the same account data/profile as you would get on a physical cab."
|
||||
:title="t('cfg.aime.aimedb')"
|
||||
:tooltip="t('cfg.aime.aimedbTooltip')"
|
||||
>
|
||||
<ToggleSwitch v-model="prf.current!.data.sgt.amnet.physical" />
|
||||
</OptionRow>
|
||||
</div>
|
||||
<OptionRow
|
||||
title="Aime serial port"
|
||||
tooltip="Ports can be checked in Devices and Printers or at googlechromelabs.github.io/serial-terminal
|
||||
For AIC Pico, the AIME port should be selected."
|
||||
:title="t('cfg.aime.serialPort')"
|
||||
:tooltip="t('cfg.aime.serialPortTooltip')"
|
||||
v-if="prf.current!.data.sgt.aime === 'Disabled'"
|
||||
>
|
||||
<Select
|
||||
v-model="prf.current!.data.sgt.aime_port"
|
||||
:options="[
|
||||
{ title: 'default', value: null },
|
||||
{ title: t('default'), value: null },
|
||||
...Object.entries(coms ?? {}).map(([title, value]) => {
|
||||
return {
|
||||
title,
|
||||
@ -139,7 +145,7 @@ load();
|
||||
};
|
||||
}),
|
||||
]"
|
||||
placeholder="default"
|
||||
:placeholder="t('default')"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
></Select>
|
||||
|
@ -8,11 +8,14 @@ import OptionCategory from '../OptionCategory.vue';
|
||||
import OptionRow from '../OptionRow.vue';
|
||||
import { invoke } from '../../invoke';
|
||||
import { usePrfStore } from '../../stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const capabilities: Ref<string[]> = ref([]);
|
||||
const displayList: Ref<{ title: string; value: string }[]> = ref([
|
||||
{
|
||||
title: 'Primary',
|
||||
title: t('cfg.display.primary'),
|
||||
value: 'default',
|
||||
},
|
||||
]);
|
||||
@ -25,7 +28,7 @@ const extraDisplayOptionsDisabled = computed(() => {
|
||||
const loadDisplays = () => {
|
||||
const newList = [
|
||||
{
|
||||
title: 'Primary',
|
||||
title: t('cfg.display.primary'),
|
||||
value: 'default',
|
||||
},
|
||||
];
|
||||
@ -74,7 +77,7 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
<OptionCategory title="Display">
|
||||
<OptionRow
|
||||
v-if="capabilities.includes('display')"
|
||||
title="Target display"
|
||||
:title="t('cfg.display.target')"
|
||||
>
|
||||
<Select
|
||||
v-model="prf.current!.data.display.target"
|
||||
@ -108,7 +111,7 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
v-model="prf.current!.data.display.rez[1]"
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow title="Display mode">
|
||||
<OptionRow :title="t('cfg.display.mode')">
|
||||
<SelectButton
|
||||
v-model="prf.current!.data.display.mode"
|
||||
:options="[
|
||||
@ -122,7 +125,7 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Display rotation"
|
||||
:title="t('cfg.display.rotation')"
|
||||
v-if="capabilities.includes('display')"
|
||||
>
|
||||
<SelectButton
|
||||
@ -130,12 +133,18 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
:options="
|
||||
isVertical
|
||||
? [
|
||||
{ title: 'Portrait', value: 90 },
|
||||
{ title: 'Portrait (flipped)', value: 270 },
|
||||
{ title: t('cfg.display.portrait'), value: 90 },
|
||||
{
|
||||
title: `${t('cfg.display.portrait')} (${t('cfg.display.flipped')})`,
|
||||
value: 270,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ title: 'Landscape', value: 0 },
|
||||
{ title: 'Landscape (flipped)', value: 180 },
|
||||
{ title: t('cfg.display.landscape'), value: 0 },
|
||||
{
|
||||
title: `${t('cfg.display.landscape')} (${t('cfg.display.flipped')})`,
|
||||
value: 180,
|
||||
},
|
||||
]
|
||||
"
|
||||
:allow-empty="true"
|
||||
@ -147,7 +156,7 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
<OptionRow
|
||||
v-if="capabilities.includes('display')"
|
||||
class="number-input"
|
||||
title="Refresh Rate"
|
||||
:title="t('cfg.display.refreshRate')"
|
||||
>
|
||||
<InputNumber
|
||||
v-if="game === 'ongeki'"
|
||||
@ -173,9 +182,9 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Borderless fullscreen"
|
||||
:title="t('cfg.display.borderlessFullscreen')"
|
||||
v-if="capabilities.includes('display')"
|
||||
tooltip="Match display resolution with the game."
|
||||
:tooltip="t('cfg.display.borderlessFullscreenTooltip')"
|
||||
>
|
||||
<ToggleSwitch
|
||||
:disabled="
|
||||
@ -186,7 +195,7 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Skip switching primary display"
|
||||
:title="t('cfg.display.dontSwitchPrimary')"
|
||||
v-if="
|
||||
capabilities.includes('display') &&
|
||||
prf.current?.data.display.target !== 'default' &&
|
||||
@ -194,7 +203,7 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
displayList.length > 2) &&
|
||||
canSkipPrimarySwitch
|
||||
"
|
||||
dangerous-tooltip="Only enable this option if switching the primary display causes issues. The monitors must have a matching refresh rate."
|
||||
:dangerous-tooltip="t('cfg.display.dontSwitchPrimaryTooltip')"
|
||||
>
|
||||
<ToggleSwitch
|
||||
:disabled="extraDisplayOptionsDisabled"
|
||||
@ -202,7 +211,7 @@ const canSkipPrimarySwitch = game === 'ongeki';
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Display index"
|
||||
:title="t('cfg.display.index')"
|
||||
class="number-input"
|
||||
v-if="
|
||||
capabilities.includes('display') &&
|
||||
|
@ -5,27 +5,27 @@ import KeyboardKey from '../KeyboardKey.vue';
|
||||
import OptionCategory from '../OptionCategory.vue';
|
||||
import OptionRow from '../OptionRow.vue';
|
||||
import { usePrfStore } from '../../stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OptionCategory title="Keyboard">
|
||||
<OptionRow
|
||||
title="Enable"
|
||||
tooltip="Only applicable if the IO module is set to segatools built-in (keyboard) or a compatible third-party module (like mu3io.NET)"
|
||||
>
|
||||
<OptionCategory :title="t('cfg.keyboard.title')">
|
||||
<OptionRow :title="t('enable')" :tooltip="t('cfg.keyboard.tooltip')">
|
||||
<ToggleSwitch v-model="prf.current!.data.keyboard!.data.enabled" />
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Lever mode"
|
||||
:title="t('cfg.keyboard.leverMode')"
|
||||
v-if="prf.current!.data.keyboard!.game === 'Ongeki'"
|
||||
>
|
||||
<SelectButton
|
||||
v-model="prf.current!.data.keyboard!.data.use_mouse"
|
||||
:options="[
|
||||
{ title: 'XInput', value: false },
|
||||
{ title: 'Mouse', value: true },
|
||||
{ title: t('cfg.keyboard.mouse'), value: true },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
|
@ -4,18 +4,24 @@ import FileEditor from '../FileEditor.vue';
|
||||
import OptionCategory from '../OptionCategory.vue';
|
||||
import OptionRow from '../OptionRow.vue';
|
||||
import { usePrfStore } from '../../stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OptionCategory title="Misc">
|
||||
<OptionRow title="OpenSSL bug workaround for Intel ≥10th gen">
|
||||
<OptionCategory :title="t('cfg.misc.title')">
|
||||
<OptionRow
|
||||
:title="t('cfg.misc.intel')"
|
||||
:dangerous-tooltip="t('cfg.misc.intelTooltip')"
|
||||
>
|
||||
<ToggleSwitch v-model="prf.current!.data.sgt.intel" />
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="More segatools options"
|
||||
tooltip="Advanced options not covered by STARTLINER"
|
||||
:title="t('cfg.misc.other')"
|
||||
:tooltip="t('cfg.misc.otherTooltip')"
|
||||
>
|
||||
<!-- <Button icon="pi pi-refresh" size="small" /> -->
|
||||
<FileEditor filename="segatools-base.ini" />
|
||||
|
@ -6,18 +6,21 @@ import FilePicker from '../FilePicker.vue';
|
||||
import OptionCategory from '../OptionCategory.vue';
|
||||
import OptionRow from '../OptionRow.vue';
|
||||
import { usePrfStore } from '../../stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OptionCategory title="Network">
|
||||
<OptionRow title="Network type">
|
||||
<OptionCategory :title="t('cfg.network.title')">
|
||||
<OptionRow :title="t('cfg.network.type')">
|
||||
<SelectButton
|
||||
v-model="prf.current!.data.network.network_type"
|
||||
:options="[
|
||||
{ title: 'Remote', value: 'Remote' },
|
||||
{ title: 'Local (ARTEMiS)', value: 'Artemis' },
|
||||
{ title: t('cfg.network.remote'), value: 'Remote' },
|
||||
{ title: t('cfg.network.localArtemis'), value: 'Artemis' },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
@ -25,8 +28,8 @@ const prf = usePrfStore();
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
v-if="prf.current!.data.network.network_type == 'Artemis'"
|
||||
title="ARTEMiS path"
|
||||
v-if="prf.current!.data.network.network_type === 'Artemis'"
|
||||
:title="t('cfg.network.artemisPath')"
|
||||
>
|
||||
<FilePicker
|
||||
:directory="false"
|
||||
@ -47,7 +50,7 @@ const prf = usePrfStore();
|
||||
</OptionRow> -->
|
||||
<OptionRow
|
||||
v-if="prf.current!.data.network.network_type == 'Remote'"
|
||||
title="Server address"
|
||||
:title="t('cfg.network.address')"
|
||||
>
|
||||
<InputText
|
||||
class="shrink"
|
||||
@ -58,7 +61,7 @@ const prf = usePrfStore();
|
||||
/> </OptionRow
|
||||
><OptionRow
|
||||
v-if="prf.current!.data.network.network_type == 'Remote'"
|
||||
title="Keychip"
|
||||
:title="t('cfg.network.keychip')"
|
||||
>
|
||||
<InputText
|
||||
class="shrink"
|
||||
@ -67,7 +70,7 @@ const prf = usePrfStore();
|
||||
placeholder="A123-01234567890"
|
||||
v-model="prf.current!.data.network.keychip"
|
||||
/> </OptionRow
|
||||
><OptionRow title="Subnet">
|
||||
><OptionRow :title="t('cfg.network.subnet')">
|
||||
<InputText
|
||||
class="shrink"
|
||||
size="small"
|
||||
@ -76,7 +79,7 @@ const prf = usePrfStore();
|
||||
v-model="prf.current!.data.network.subnet"
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow title="Address suffix">
|
||||
<OptionRow :title="t('cfg.network.addrSuffix')">
|
||||
<InputNumber
|
||||
class="shrink"
|
||||
size="small"
|
||||
|
@ -11,6 +11,9 @@ import { invoke } from '../../invoke';
|
||||
import { usePkgStore, usePrfStore } from '../../stores';
|
||||
import { Feature } from '../../types';
|
||||
import { pkgKey } from '../../util';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const prf = usePrfStore();
|
||||
const pkgs = usePkgStore();
|
||||
@ -54,10 +57,10 @@ const checkSegatoolsIni = async (target: string) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OptionCategory title="General">
|
||||
<OptionCategory :title="t('cfg.segatools.general')">
|
||||
<OptionRow
|
||||
:title="names.exe"
|
||||
tooltip="STARTLINER expects unpacked executables put into otherwise clean data."
|
||||
:tooltip="t('cfg.segatools.targetTooltip')"
|
||||
>
|
||||
<FilePicker
|
||||
:directory="false"
|
||||
@ -104,7 +107,11 @@ const checkSegatoolsIni = async (target: string) => {
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
:title="names.hook"
|
||||
tooltip="Hooks can be downloaded from the package store."
|
||||
:tooltip="
|
||||
t('cfg.segatools.installTooltip', {
|
||||
thing: t('cfg.segatools.hooks'),
|
||||
})
|
||||
"
|
||||
>
|
||||
<Select
|
||||
v-model="prf.current!.data.sgt.hook"
|
||||
@ -126,7 +133,11 @@ const checkSegatoolsIni = async (target: string) => {
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
:title="names.io"
|
||||
tooltip="IO plugins can be downloaded from the package store."
|
||||
:tooltip="
|
||||
t('cfg.segatools.installTooltip', {
|
||||
thing: t('cfg.segatools.ioModules'),
|
||||
})
|
||||
"
|
||||
>
|
||||
<Select
|
||||
v-model="prf.current!.data.sgt.io2"
|
||||
|
@ -1,92 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import OptionCategory from '../OptionCategory.vue';
|
||||
import OptionRow from '../OptionRow.vue';
|
||||
import { useClientStore } from '../../stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const client = useClientStore();
|
||||
|
||||
const offlineModel = computed({
|
||||
get() {
|
||||
return client.offlineMode;
|
||||
},
|
||||
async set(value: boolean) {
|
||||
await client.setOfflineMode(value);
|
||||
},
|
||||
});
|
||||
|
||||
const updatesModel = computed({
|
||||
get() {
|
||||
return client.enableAutoupdates;
|
||||
},
|
||||
async set(value: boolean) {
|
||||
await client.setAutoupdates(value);
|
||||
},
|
||||
});
|
||||
|
||||
const verboseModel = computed({
|
||||
get() {
|
||||
return client.verbose;
|
||||
},
|
||||
async set(value: boolean) {
|
||||
await client.setVerbose(value);
|
||||
},
|
||||
});
|
||||
|
||||
const themeModel = computed({
|
||||
get() {
|
||||
return client.theme;
|
||||
},
|
||||
async set(value: 'light' | 'dark' | 'system') {
|
||||
await client.setTheme(value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OptionCategory title="STARTLINER">
|
||||
<OptionRow title="UI scaling">
|
||||
<SelectButton
|
||||
v-model="client.scaleModel"
|
||||
:options="[
|
||||
{ title: 'S', value: 's' },
|
||||
{ title: 'M', value: 'm' },
|
||||
{ title: 'L', value: 'l' },
|
||||
{ title: 'XL', value: 'xl' },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
<OptionRow
|
||||
:title="t('cfg.startliner.offlineMode')"
|
||||
:tooltip="`${t('cfg.startliner.offlineModeTooltip')} ${t('cfg.afterRestart')}`"
|
||||
>
|
||||
<ToggleSwitch
|
||||
:model-value="client.offlineMode"
|
||||
@update:model-value="
|
||||
async (v) => await client.setOfflineMode(v)
|
||||
"
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Offline mode"
|
||||
tooltip="Disables the package store. Applies after a restart."
|
||||
>
|
||||
<ToggleSwitch v-model="offlineModel" />
|
||||
</OptionRow>
|
||||
<OptionRow title="Enable automatic updates">
|
||||
<ToggleSwitch v-model="updatesModel" />
|
||||
<OptionRow :title="t('cfg.startliner.autoUpdate')">
|
||||
<ToggleSwitch
|
||||
:model-value="client.enableAutoupdates"
|
||||
@update:model-value="
|
||||
async (v) => await client.setAutoupdates(v)
|
||||
"
|
||||
></ToggleSwitch>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Enable detailed logs"
|
||||
tooltip="Applies after a restart."
|
||||
:title="t('cfg.startliner.verbose')"
|
||||
:tooltip="t('cfg.afterRestart')"
|
||||
>
|
||||
<ToggleSwitch v-model="verboseModel" />
|
||||
</OptionRow>
|
||||
<OptionRow title="Theme">
|
||||
<SelectButton
|
||||
v-model="themeModel"
|
||||
:options="[
|
||||
{ title: 'System', value: 'system' },
|
||||
{ title: 'Light', value: 'light' },
|
||||
{ title: 'Dark', value: 'dark' },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
<ToggleSwitch
|
||||
:model-value="client.verbose"
|
||||
@update:model-value="async (v) => await client.setVerbose(v)"
|
||||
/>
|
||||
</OptionRow>
|
||||
</OptionCategory>
|
||||
|
Reference in New Issue
Block a user