Files
STARTLINER/src/components/ModList.vue
2025-04-28 22:00:33 +00:00

357 lines
11 KiB
Vue

<script setup lang="ts">
import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Fieldset from 'primevue/fieldset';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch';
import { emit } from '@tauri-apps/api/event';
import ModListEntry from './ModListEntry.vue';
import ModTitlecard from './ModTitlecard.vue';
import { invoke } from '../invoke';
import { useClientStore, usePkgStore, usePrfStore } from '../stores';
import { Feature, Game, Package } from '../types';
import { pkgKey } from '../util';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
search: String,
});
const pkgs = usePkgStore();
const client = useClientStore();
const prf = usePrfStore();
const gameSublist: Ref<string[]> = ref([]);
const loadPackages = () => {
invoke('get_game_packages', {
game: prf.current?.meta.game ?? null,
}).then((list) => {
gameSublist.value = list as string[];
});
};
loadPackages();
const allCategories = computed(() => {
const res = new Set<string>();
for (const pkg of pkgs.allLocal) {
for (const cat of pkg.rmt?.categories ?? []) {
res.add(cat);
}
}
return [...res.values()].sort((a, b) => a.localeCompare(b));
});
const local = computed(() => {
return pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter((p) => p.namespace === 'local');
});
const groupedList = computed(() => {
const searchedPkgs = pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter((p) => p.namespace !== 'local')
.filter(
(p) =>
props.search === undefined ||
p.name.toLowerCase().includes(props.search.toLowerCase()) ||
p.namespace.toLowerCase().includes(props.search.toLowerCase())
);
let grouped;
if (client.pkgListMode === 'namespace') {
grouped = Object.groupBy(searchedPkgs, ({ namespace }) => namespace);
} else if (client.pkgListMode === 'type') {
grouped = {
standard: [] as Package[],
native: [] as Package[],
segatools: [] as Package[],
unsupported: [] as Package[] | undefined,
};
grouped.unsupported = [];
for (const pkg of searchedPkgs) {
const loc = pkg.loc;
if (!loc || !loc.status || typeof loc.status === 'string') {
grouped.unsupported.push(pkg);
} else {
if (
loc.status.OK[0] &
(Feature.GameDLL | Feature.Mempatcher | Feature.AmdDLL)
) {
grouped.native.push(pkg);
} else if (loc.status.OK[0] & Feature.Mod) {
grouped.standard.push(pkg);
}
if (
loc.status.OK[0] &
(Feature.AMNet |
Feature.Aime |
Feature.ChuniIO |
Feature.ChusanHook |
Feature.Mu3IO |
Feature.Mu3Hook)
) {
grouped.segatools.push(pkg);
}
}
}
if (grouped.unsupported.length === 0) {
delete grouped.unsupported;
}
} else {
grouped = {} as { [key: string]: Package[] };
for (const pkg of searchedPkgs) {
for (const cat of pkg.rmt?.categories ?? []) {
if (client.hiddenCategories.includes(cat)) {
continue;
}
if (!(cat in grouped)) {
grouped[cat] = [] as Package[];
}
grouped[cat].push(pkg);
}
}
}
let res: [string, Package[]][] = [];
for (const [k, v] of Object.entries(grouped)) {
if (v !== undefined) {
res.push([k, v]);
}
}
if (
client.pkgListMode === 'namespace' ||
client.pkgListMode === 'category'
) {
res.sort((a, b) => `${a[0]}`.localeCompare(`${b[0]}`));
} else if (client.pkgListMode === 'type') {
for (const entry of res) {
entry[0] = t(`pkglist.${entry[0]}`);
}
}
return res;
});
const missing = computed(() => {
return prf.current?.data.mods.filter((m) => !pkgs.hasLocal(m)) ?? [];
});
const dialogVisible = ref(false);
const defaultModel = {
name: '',
description: '',
website: '',
type: 'rainy',
games: [] as string[],
};
const creatorModel = ref({ ...defaultModel });
const gameModel = (game: Game) =>
computed({
get() {
return (creatorModel.value.games as string[]).includes(game);
},
set(v: boolean) {
creatorModel.value.games = creatorModel.value.games.filter(
(g) => g !== game
);
if (v) {
creatorModel.value.games.push(game);
}
},
});
const gameModelOngeki = gameModel('ongeki');
const gameModelChunithm = gameModel('chunithm');
</script>
<template>
<Dialog
modal
:visible="dialogVisible"
:closable="false"
:header="t('creator.header')"
:style="{ width: '500px', scale: client.scaleValue }"
class="creation-dialog"
>
<div style="position: absolute; left: 250px; top: 25px">
<a
href="https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Package-format"
target="_blank"
style="text-decoration: underline"
class="self-center"
>{{ t('creator.packageFormat') }}</a
>
</div>
<h2>{{ t('creator.basic') }}</h2>
<div class="flex flex-col gap-3">
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.name')"
v-model="creatorModel.name"
/>
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.description')"
v-model="creatorModel.description"
/>
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.website')"
v-model="creatorModel.website"
/>
</div>
<h2>{{ t('creator.type') }}</h2>
<div class="flex flex-col items-center">
<SelectButton
:options="[
{ title: t('creator.rainy'), value: 'rainy' },
{ title: t('creator.native'), value: 'native' },
{ title: t('creator.segatools'), value: 'segatools' },
]"
v-model="creatorModel.type"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</div>
<h2>{{ t('creator.games') }}</h2>
<div class="flex flex-col gap-4">
<div class="flex flex-row">
<div class="grow">{{ t('game.ongeki') }}</div>
<ToggleSwitch v-model="gameModelOngeki" />
</div>
<div class="flex flex-row">
<div class="grow">{{ t('game.chunithm') }}</div>
<ToggleSwitch v-model="gameModelChunithm" />
</div>
</div>
<div class="flex flex-row mt-5">
<Button
class="ml-auto mr-1"
style="width: 80px"
:label="t('ok')"
:disabled="creatorModel.games.length === 0"
@click="
async () => {
await invoke('create_package', creatorModel);
await pkgs.reloadAll();
loadPackages();
dialogVisible = false;
creatorModel = { ...defaultModel };
}
"
/>
<Button
class="mr-auto ml-1"
style="width: 80px"
:label="t('cancel')"
@click="
() => (
(dialogVisible = false),
(creatorModel = { ...defaultModel })
)
"
/>
</div>
</Dialog>
<div class="flex flex-row">
<SelectButton
:options="[
{ title: t('pkglist.namespace'), value: 'namespace' },
{ title: t('pkglist.type'), value: 'type' },
{ title: t('pkglist.category'), value: 'category' },
]"
v-model="client.pkgListMode"
v-on:update:model-value="
client.save();
emit('reload-icons');
"
:allow-empty="false"
option-label="title"
option-value="value"
/>
<div
class="grow text-right mr-2 self-center"
v-if="client.pkgListMode === 'category'"
>
{{ t('pkglist.exclusions') }}
</div>
<MultiSelect
v-if="client.pkgListMode === 'category'"
style="width: 30%"
:showToggleAll="false"
v-model="client.hiddenCategories"
v-on:value-change="
client.save();
emit('reload-icons');
"
:options="allCategories"
class="w-full grow"
/>
</div>
<Fieldset :legend="t('pkglist.missing')" v-if="(missing?.length ?? 0) > 0">
<div class="flex items-center" v-for="p in missing">
<ModTitlecard
show-namespace
:pkg="
{
namespace: p.split('-')[0],
name: p.split('-')[1],
} as Package
"
/>
<Button
rounded
icon="pi pi-minus"
severity="danger"
aria-label="install"
size="small"
class="self-center ml-4"
style="width: 2rem; height: 2rem"
v-on:click="prf.togglePkg(p, false)"
/>
</div>
</Fieldset>
<Fieldset :legend="t('pkglist.local')">
<ModListEntry v-for="p in local" :pkg="p" />
<Button
rounded
icon="pi pi-plus"
severity="success"
aria-label="install"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
v-on:click="() => (dialogVisible = true)"
/>
</Fieldset>
<Fieldset v-for="[namespace, pkgs] in groupedList" :legend="namespace">
<ModListEntry v-for="p in pkgs" :pkg="p" />
</Fieldset>
</template>
<style lang="css" scoped>
.creation-dialog h2 {
margin-top: 0.6em;
margin-bottom: 0.4em;
font-size: 110%;
}
</style>