refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes * feat: move files tab to shared layout structure * fix: qa * fix: qa * fix: bugs * fix: lint * fix: admonition cleanup with progress + actions * fix: cleanup * fix: modals * fix: admon title * fix: i18n standard * fix: lint + i18n pass * fix: remove transition * fix: type errors * feat: files tab in app * fix: qa * fix: backup item minmax * fix: use ContentPageHeader for server panel * fix: lint * fix: lint * fix: lint * feat: page leave safety * fix: lint * fix: cargo fmt fix * fix: blank in prod * fix: content card table stuff * Revert "fix: blank in prod" This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace. * fix: import * feat: browse worlds/servers flow * fix: worlds tab parity with content tab * fix: perf bug + shader filter pill copy * feat: singleplayer filter * fix: ordering * fix: breadcrumbs * fix: lint * fix: qa * feat: store server proj id when adding to a non-linked instance * fix: lint * fix: i18n + qa * fix: conflict * qa: already installed modal + placeholders not server-specific * fix: qa * fix: add + edit server modals * fix: qa * fix: security * fix: devin flags * fix: lint * chore: change file to break build cache * fix: admon * fix: import path stuff * feat: qa * fix: fmt fmt idiot --------- Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
@@ -15,48 +15,88 @@
|
||||
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
|
||||
<ConfirmModalWrapper
|
||||
ref="removeServerModal"
|
||||
:title="`Are you sure you want to remove ${serverToRemove?.name ?? 'this server'}?`"
|
||||
:description="`'${serverToRemove?.name}'${serverToRemove?.address === serverToRemove?.name ? ' ' : ` (${serverToRemove?.address})`} will be removed from your list, including in-game, and there will be no way to recover it.`"
|
||||
:title="
|
||||
formatMessage(messages.removeServerTitle, {
|
||||
name: serverToRemove?.name ?? formatMessage(messages.thisServer),
|
||||
})
|
||||
"
|
||||
:description="
|
||||
serverToRemove?.address === serverToRemove?.name
|
||||
? formatMessage(messages.removeServerDescription, { name: serverToRemove?.name })
|
||||
: formatMessage(messages.removeServerDescriptionWithAddress, {
|
||||
name: serverToRemove?.name,
|
||||
address: serverToRemove?.address,
|
||||
})
|
||||
"
|
||||
:markdown="false"
|
||||
@proceed="proceedRemoveServer"
|
||||
/>
|
||||
<ConfirmModalWrapper
|
||||
ref="deleteWorldModal"
|
||||
:title="`Are you sure you want to permanently delete this world?`"
|
||||
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
|
||||
:title="formatMessage(messages.deleteWorldTitle)"
|
||||
:description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
|
||||
@proceed="proceedDeleteWorld"
|
||||
/>
|
||||
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
placeholder="Search worlds..."
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
clearable
|
||||
wrapper-class="flex-grow"
|
||||
:placeholder="
|
||||
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
|
||||
"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<template v-if="refreshingAll">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Refreshing...
|
||||
</template>
|
||||
<template v-else>
|
||||
<UpdatedIcon />
|
||||
Refresh
|
||||
</template>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<FilterIcon class="size-5 text-secondary" />
|
||||
<button
|
||||
:class="filterPillClass(selectedFilters.length === 0)"
|
||||
@click="selectedFilters = []"
|
||||
>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="addServerModal?.show()">
|
||||
<PlusIcon />
|
||||
Add a server
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
:class="filterPillClass(selectedFilters.includes(option.id))"
|
||||
@click="toggleFilter(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
<ButtonStyled type="transparent" hover-color-fill="none">
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<FilterBar v-model="filters" :options="filterOptions" show-all-options />
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<WorldItem
|
||||
v-for="world in filteredWorlds"
|
||||
@@ -92,51 +132,49 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
|
||||
<RadialHeader class="">
|
||||
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
||||
<img src="@/assets/sad-modrinth-bot.webp" alt="" aria-hidden="true" class="h-24" />
|
||||
<span class="text-contrast font-bold text-xl"> You don't have any worlds yet. </span>
|
||||
</div>
|
||||
</RadialHeader>
|
||||
<div class="flex gap-2 mt-4 mx-auto">
|
||||
<ButtonStyled>
|
||||
<button @click="addServerModal?.show()">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add a server
|
||||
<EmptyState
|
||||
v-else
|
||||
type="empty-inbox"
|
||||
:heading="formatMessage(messages.noWorldsHeading)"
|
||||
:description="formatMessage(messages.noWorldsDescription)"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<template v-if="refreshingAll">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin" />
|
||||
Refreshing...
|
||||
</template>
|
||||
<template v-else>
|
||||
<UpdatedIcon aria-hidden="true" />
|
||||
Refresh
|
||||
</template>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, SearchIcon, SpinnerIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { CompassIcon, FilterIcon, PlusIcon, RefreshCwIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FilterBar,
|
||||
type FilterBarOption,
|
||||
EmptyState,
|
||||
GAME_MODES,
|
||||
type GameVersion,
|
||||
injectNotificationManager,
|
||||
RadialHeader,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
@@ -176,11 +214,80 @@ import {
|
||||
type World,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install'
|
||||
|
||||
const messages = defineMessages({
|
||||
removeServerTitle: {
|
||||
id: 'app.instance.worlds.remove-server-title',
|
||||
defaultMessage: 'Are you sure you want to remove {name}?',
|
||||
},
|
||||
removeServerDescription: {
|
||||
id: 'app.instance.worlds.remove-server-description',
|
||||
defaultMessage:
|
||||
"'{name}' will be removed from your list, including in-game, and there will be no way to recover it.",
|
||||
},
|
||||
removeServerDescriptionWithAddress: {
|
||||
id: 'app.instance.worlds.remove-server-description-with-address',
|
||||
defaultMessage:
|
||||
"'{name}' ({address}) will be removed from your list, including in-game, and there will be no way to recover it.",
|
||||
},
|
||||
deleteWorldTitle: {
|
||||
id: 'app.instance.worlds.delete-world-title',
|
||||
defaultMessage: 'Are you sure you want to permanently delete this world?',
|
||||
},
|
||||
deleteWorldDescription: {
|
||||
id: 'app.instance.worlds.delete-world-description',
|
||||
defaultMessage:
|
||||
"'{name}' will be **permanently deleted**, and there will be no way to recover it.",
|
||||
},
|
||||
searchWorldsPlaceholder: {
|
||||
id: 'app.instance.worlds.search-worlds-placeholder',
|
||||
defaultMessage: 'Search {count} worlds...',
|
||||
},
|
||||
addServer: {
|
||||
id: 'app.instance.worlds.add-server',
|
||||
defaultMessage: 'Add server',
|
||||
},
|
||||
browseServers: {
|
||||
id: 'app.instance.worlds.browse-servers',
|
||||
defaultMessage: 'Browse servers',
|
||||
},
|
||||
noWorldsHeading: {
|
||||
id: 'app.instance.worlds.no-worlds-heading',
|
||||
defaultMessage: 'No servers or worlds added',
|
||||
},
|
||||
noWorldsDescription: {
|
||||
id: 'app.instance.worlds.no-worlds-description',
|
||||
defaultMessage: 'Add a server or browse to get started',
|
||||
},
|
||||
thisServer: {
|
||||
id: 'app.instance.worlds.this-server',
|
||||
defaultMessage: 'this server',
|
||||
},
|
||||
vanillaFilter: {
|
||||
id: 'app.instance.worlds.filter-vanilla',
|
||||
defaultMessage: 'Vanilla',
|
||||
},
|
||||
moddedFilter: {
|
||||
id: 'app.instance.worlds.filter-modded',
|
||||
defaultMessage: 'Modded',
|
||||
},
|
||||
onlineFilter: {
|
||||
id: 'app.instance.worlds.filter-online',
|
||||
defaultMessage: 'Online',
|
||||
},
|
||||
offlineFilter: {
|
||||
id: 'app.instance.worlds.filter-offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { playServerProject } = injectServerInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
|
||||
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
|
||||
@@ -211,9 +318,32 @@ function play(world: World) {
|
||||
emit('play', world)
|
||||
}
|
||||
|
||||
const filters = ref<string[]>([])
|
||||
const selectedFilters = ref<string[]>([])
|
||||
const searchFilter = ref('')
|
||||
|
||||
function filterPillClass(isActive: boolean) {
|
||||
return [
|
||||
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
|
||||
isActive
|
||||
? 'border-green bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
|
||||
]
|
||||
}
|
||||
|
||||
function toggleFilter(id: string) {
|
||||
const idx = selectedFilters.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
selectedFilters.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedFilters.value.push(id)
|
||||
if (id === 'singleplayer') {
|
||||
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'online' && f !== 'offline')
|
||||
} else if (id === 'online' || id === 'offline') {
|
||||
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'singleplayer')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshingAll = ref(false)
|
||||
const hadNoWorlds = ref(true)
|
||||
const startingInstance = ref(false)
|
||||
@@ -371,6 +501,12 @@ async function editServer(server: ServerWorld) {
|
||||
async function removeServer(server: ServerWorld) {
|
||||
await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
|
||||
let serverIdx = 0
|
||||
for (const w of worlds.value) {
|
||||
if (w.type === 'server') {
|
||||
w.index = serverIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function editWorld(path: string, name: string, removeIcon: boolean) {
|
||||
@@ -393,7 +529,7 @@ async function deleteWorld(world: SingleplayerWorld) {
|
||||
}
|
||||
|
||||
function handleJoinError(err: Error) {
|
||||
handleError(err)
|
||||
handleSevereError(err, { profilePath: instance.value.path })
|
||||
startingInstance.value = false
|
||||
worldPlaying.value = undefined
|
||||
}
|
||||
@@ -498,58 +634,83 @@ const dedupedWorlds = computed(() => {
|
||||
})
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const options: FilterBarOption[] = []
|
||||
|
||||
const options: { id: string; label: string }[] = []
|
||||
const hasSingleplayer = dedupedWorlds.value.some((x) => x.type === 'singleplayer')
|
||||
const hasServer = dedupedWorlds.value.some((x) => x.type === 'server')
|
||||
|
||||
if (dedupedWorlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
|
||||
options.push({
|
||||
id: 'singleplayer',
|
||||
message: messages.singleplayer,
|
||||
})
|
||||
options.push({
|
||||
id: 'server',
|
||||
message: messages.server,
|
||||
})
|
||||
const hasStatusFilter =
|
||||
selectedFilters.value.includes('online') || selectedFilters.value.includes('offline')
|
||||
|
||||
if (hasSingleplayer && hasServer && !hasStatusFilter) {
|
||||
options.push({ id: 'singleplayer', label: formatMessage(commonMessages.singleplayerLabel) })
|
||||
}
|
||||
|
||||
if (hasServer) {
|
||||
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
|
||||
if (
|
||||
dedupedWorlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'server' &&
|
||||
!serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing,
|
||||
) &&
|
||||
dedupedWorlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'singleplayer' ||
|
||||
(x.type === 'server' &&
|
||||
serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing),
|
||||
)
|
||||
) {
|
||||
options.push({
|
||||
id: 'available',
|
||||
message: messages.available,
|
||||
})
|
||||
const servers = dedupedWorlds.value.filter((x) => x.type === 'server')
|
||||
const hasVanilla = servers.some((x) => x.content_kind !== 'modpack')
|
||||
const hasModded = servers.some((x) => x.content_kind === 'modpack')
|
||||
if (hasVanilla && hasModded) {
|
||||
options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) })
|
||||
options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) })
|
||||
}
|
||||
if (!selectedFilters.value.includes('singleplayer')) {
|
||||
const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
|
||||
const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
|
||||
if (hasOnline && hasOffline) {
|
||||
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
|
||||
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
watch(filterOptions, (options) => {
|
||||
const validIds = new Set(options.map((opt) => opt.id))
|
||||
const cleaned = selectedFilters.value.filter((f) => validIds.has(f))
|
||||
if (cleaned.length !== selectedFilters.value.length) {
|
||||
selectedFilters.value = cleaned
|
||||
}
|
||||
})
|
||||
|
||||
const filteredWorlds = computed(() =>
|
||||
dedupedWorlds.value.filter((x) => {
|
||||
const availableFilter = filters.value.includes('available')
|
||||
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
|
||||
if (searchFilter.value && !x.name.toLowerCase().includes(searchFilter.value.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
(!typeFilter || filters.value.includes(x.type)) &&
|
||||
(!availableFilter || x.type !== 'server' || serverData.value[x.address]?.status) &&
|
||||
(!searchFilter.value || x.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
|
||||
)
|
||||
if (selectedFilters.value.length === 0) return true
|
||||
|
||||
const hasSingleplayerFilter = selectedFilters.value.includes('singleplayer')
|
||||
const typeFilters = selectedFilters.value.filter((f) => f === 'vanilla' || f === 'modded')
|
||||
const statusFilters = selectedFilters.value.filter((f) => f === 'online' || f === 'offline')
|
||||
|
||||
if (x.type === 'singleplayer') {
|
||||
return hasSingleplayerFilter || (typeFilters.length === 0 && statusFilters.length === 0)
|
||||
}
|
||||
|
||||
if (hasSingleplayerFilter && typeFilters.length === 0 && statusFilters.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let passesType = true
|
||||
if (typeFilters.length > 0) {
|
||||
const isModded = x.content_kind === 'modpack'
|
||||
passesType =
|
||||
(typeFilters.includes('modded') && isModded) ||
|
||||
(typeFilters.includes('vanilla') && !isModded)
|
||||
}
|
||||
|
||||
let passesStatus = true
|
||||
if (statusFilters.length > 0) {
|
||||
const isOnline = !!serverData.value[x.address]?.status
|
||||
passesStatus =
|
||||
(statusFilters.includes('online') && isOnline) ||
|
||||
(statusFilters.includes('offline') && !isOnline)
|
||||
}
|
||||
|
||||
return passesType && passesStatus
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -588,19 +749,4 @@ async function proceedDeleteWorld() {
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
singleplayer: {
|
||||
id: 'instance.worlds.type.singleplayer',
|
||||
defaultMessage: 'Singleplayer',
|
||||
},
|
||||
server: {
|
||||
id: 'instance.worlds.type.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
available: {
|
||||
id: 'instance.worlds.filter.available',
|
||||
defaultMessage: 'Available',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user