feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -1,64 +1,147 @@
<template>
<div data-tauri-drag-region class="flex items-center gap-1 pl-3">
<Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon />
</Button>
<Button
v-if="false"
class="breadcrumbs__forward transparent"
icon-only
@click="$router.forward()"
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
<ChevronRightIcon />
</Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query,
}"
class="text-primary"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}
</router-link>
<span
v-else
data-tauri-drag-region
class="text-contrast font-semibold cursor-default select-none"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}</span
>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" />
</template>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="shrink-0 whitespace-nowrap text-primary"
>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
data-tauri-drag-region
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
</template>
</div>
</div>
</template>
<script setup>
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { computed } from 'vue'
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const route = useRoute()
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed(() => {
const breadcrumbs = computed<Breadcrumb[]>(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return name.charAt(0) === '?' ? breadcrumbData.getName(name.slice(1)) : name
}
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
const innerRef = ref<HTMLDivElement | null>(null)
const isOverflowing = ref(false)
const isAnimating = ref(false)
const overflowAmount = ref(0)
let hovered = false
let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
}
function onMouseEnter() {
hovered = true
stopping = false
if (isOverflowing.value) {
isAnimating.value = true
}
}
function onMouseLeave() {
hovered = false
if (isAnimating.value) {
stopping = true
}
}
function onAnimationIteration() {
if (stopping && !hovered) {
isAnimating.value = false
stopping = false
}
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
checkOverflow()
resizeObserver = new ResizeObserver(checkOverflow)
if (outerRef.value) resizeObserver.observe(outerRef.value)
if (innerRef.value) resizeObserver.observe(innerRef.value)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
watch(breadcrumbs, () => {
requestAnimationFrame(checkOverflow)
})
</script>
<style scoped>
.breadcrumbs-scroll {
animation: breadcrumb-scroll 10s ease-in-out infinite;
}
@keyframes breadcrumb-scroll {
0% {
transform: translateX(0);
}
35%,
65% {
transform: translateX(var(--scroll-distance));
}
100% {
transform: translateX(0);
}
}
</style>

View File

@@ -1,6 +1,14 @@
<script setup>
import { PlusIcon, XIcon } from '@modrinth/assets'
import { Button, Checkbox, injectNotificationManager, StyledInput } from '@modrinth/ui'
import {
Button,
Checkbox,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
@@ -9,6 +17,33 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
modpackNamePlaceholder: {
id: 'app.export-modal.modpack-name-placeholder',
defaultMessage: 'Modpack name',
},
versionNumberLabel: {
id: 'app.export-modal.version-number-label',
defaultMessage: 'Version number',
},
versionNumberPlaceholder: {
id: 'app.export-modal.version-number-placeholder',
defaultMessage: '1.0.0',
},
descriptionPlaceholder: {
id: 'app.export-modal.description-placeholder',
defaultMessage: 'Enter modpack description...',
},
selectFilesLabel: {
id: 'app.export-modal.select-files-label',
defaultMessage: 'Select files and folders to include in pack',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
})
const props = defineProps({
instance: {
@@ -106,36 +141,36 @@ const exportPack = async () => {
</script>
<template>
<ModalWrapper ref="exportModal" header="Export modpack">
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<div class="modal-body">
<div class="labeled_input">
<p>Modpack Name</p>
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
placeholder="Modpack name"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
/>
</div>
<div class="labeled_input">
<p>Version number</p>
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
placeholder="1.0.0"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
/>
</div>
<div class="adjacent-input">
<div class="labeled_input">
<p>Description</p>
<p>{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<StyledInput
v-model="exportDescription"
multiline
placeholder="Enter modpack description..."
:placeholder="formatMessage(messages.descriptionPlaceholder)"
/>
</div>
</div>
@@ -143,7 +178,7 @@ const exportPack = async () => {
<div class="table">
<div class="table-head">
<div class="table-cell row-wise">
Select files and folders to include in pack
{{ formatMessage(messages.selectFilesLabel) }}
<Button
class="sleek-primary collapsed-button"
icon-only
@@ -202,11 +237,11 @@ const exportPack = async () => {
<div class="button-row push-right">
<Button @click="exportModal.hide">
<XIcon />
Cancel
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button color="primary" @click="exportPack">
<PackageIcon />
Export
{{ formatMessage(messages.exportButton) }}
</Button>
</div>
</div>

View File

@@ -1,662 +0,0 @@
<template>
<ModalWrapper ref="modal" header="Creating an instance">
<div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div>
<hr class="card-divider" />
<div v-if="creationType === 'custom'" class="modal-body">
<div class="image-upload">
<Avatar :src="display_icon" size="md" :rounded="true" />
<div class="image-input">
<Button @click="upload_icon()">
<UploadIcon />
Select icon
</Button>
<Button :disabled="!display_icon" @click="reset_icon">
<XIcon />
Remove icon
</Button>
</div>
</div>
<div class="input-row">
<p class="input-label">Name</p>
<StyledInput
v-model="profile_name"
autocomplete="off"
type="text"
placeholder="Enter a name for your instance..."
:maxlength="100"
wrapper-class="w-full"
/>
</div>
<div class="input-row">
<p class="input-label">Loader</p>
<Chips v-model="loader" :items="loaders" />
</div>
<div class="input-row">
<p class="input-label">Game version</p>
<div class="flex gap-4 items-center">
<multiselect
v-model="game_version"
class="selector"
:options="game_versions"
:multiple="false"
:searchable="true"
placeholder="Select game version"
open-direction="top"
:show-labels="false"
/>
<Checkbox v-model="showSnapshots" class="shrink-0" label="Show all versions" />
</div>
</div>
<div v-if="loader !== 'vanilla'" class="input-row">
<p class="input-label">Loader version</p>
<Chips v-model="loader_version" :items="['stable', 'latest', 'other']" />
</div>
<div v-if="loader_version === 'other' && loader !== 'vanilla'">
<div v-if="game_version" class="input-row">
<p class="input-label">Select version</p>
<multiselect
v-model="specified_loader_version"
class="selector"
:options="selectable_versions"
:searchable="true"
placeholder="Select loader version"
open-direction="top"
:show-labels="false"
/>
</div>
<div v-else class="input-row">
<p class="warning">Select a game version before you select a loader version</p>
</div>
</div>
<div class="input-group push-right">
<Button @click="hide()">
<XIcon />
Cancel
</Button>
<Button color="primary" :disabled="!check_valid || creating" @click="create_instance()">
<PlusIcon v-if="!creating" />
{{ creating ? 'Creating...' : 'Create' }}
</Button>
</div>
</div>
<div v-else-if="creationType === 'from file'" class="modal-body">
<Button @click="openFile"> <FolderOpenIcon /> Import from file </Button>
<div class="info"><InfoIcon /> Or drag and drop your .mrpack file</div>
</div>
<div v-else class="modal-body">
<Chips
v-model="selectedProfileType"
:items="profileOptions"
:format-label="(profile) => profile?.name"
/>
<div class="path-selection">
<h3>{{ selectedProfileType.name }} path</h3>
<div class="path-input">
<StyledInput
v-model="selectedProfileType.path"
:icon="FolderOpenIcon"
type="text"
placeholder="Path to launcher"
clearable
@change="setPath"
/>
<Button icon-only @click="selectLauncherPath">
<FolderSearchIcon />
</Button>
<Button icon-only @click="reload">
<UpdatedIcon />
</Button>
</div>
</div>
<div class="table">
<div class="table-head table-row">
<div class="toggle-all table-cell">
<Checkbox
class="select-checkbox"
:model-value="
profiles.get(selectedProfileType.name)?.every((child) => child.selected)
"
@update:model-value="
(newValue) =>
profiles
.get(selectedProfileType.name)
?.forEach((child) => (child.selected = newValue))
"
/>
</div>
<div class="name-cell table-cell">Profile name</div>
</div>
<div
v-if="
profiles.get(selectedProfileType.name) &&
profiles.get(selectedProfileType.name).length > 0
"
class="table-content"
>
<div
v-for="(profile, index) in profiles.get(selectedProfileType.name)"
:key="index"
class="table-row"
>
<div class="checkbox-cell table-cell">
<Checkbox v-model="profile.selected" class="select-checkbox" />
</div>
<div class="name-cell table-cell">
{{ profile.name }}
</div>
</div>
</div>
<div v-else class="table-content empty">No profiles found</div>
</div>
<div class="button-row">
<Button
:disabled="
loading ||
!Array.from(profiles.values())
.flatMap((e) => e)
.some((e) => e.selected)
"
color="primary"
@click="next"
>
{{
loading
? 'Importing...'
: Array.from(profiles.values())
.flatMap((e) => e)
.some((e) => e.selected)
? `Import ${
Array.from(profiles.values())
.flatMap((e) => e)
.filter((e) => e.selected).length
} profiles`
: 'Select profiles to import'
}}
</Button>
<ProgressBar
v-if="loading"
:progress="(importedProfiles / (totalProfiles + 0.0001)) * 100"
/>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import {
FolderOpenIcon,
FolderSearchIcon,
InfoIcon,
PlusIcon,
UpdatedIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
Checkbox,
Chips,
injectNotificationManager,
StyledInput,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import Multiselect from 'vue-multiselect'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_launcher_path,
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import { create } from '@/helpers/profile'
import { get_loaders } from '@/helpers/tags'
const { handleError } = injectNotificationManager()
const profile_name = ref('')
const game_version = ref('')
const loader = ref('vanilla')
const loader_version = ref('stable')
const specified_loader_version = ref('')
const icon = ref(null)
const display_icon = ref(null)
const creating = ref(false)
const showSnapshots = ref(false)
const creationType = ref('custom')
const isShowing = ref(false)
defineExpose({
show: async () => {
game_version.value = ''
specified_loader_version.value = ''
profile_name.value = ''
creating.value = false
showSnapshots.value = false
loader.value = 'vanilla'
loader_version.value = 'stable'
icon.value = null
display_icon.value = null
isShowing.value = true
modal.value.show()
unlistener.value = await getCurrentWebview().onDragDropEvent(async (event) => {
// Only if modal is showing
if (!isShowing.value) return
if (event.payload.type !== 'drop') return
if (creationType.value !== 'from file') return
hide()
const { paths } = event.payload
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
await create_profile_and_install_from_file(paths[0]).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
}
})
trackEvent('InstanceCreateStart', { source: 'CreationModal' })
},
})
const unlistener = ref(null)
const hide = () => {
isShowing.value = false
modal.value.hide()
if (unlistener.value) {
unlistener.value()
unlistener.value = null
}
}
onUnmounted(() => {
if (unlistener.value) {
unlistener.value()
unlistener.value = null
}
})
const [
fabric_versions,
forge_versions,
quilt_versions,
neoforge_versions,
all_game_versions,
loaders,
] = await Promise.all([
get_loader_versions('fabric').then(shallowRef).catch(handleError),
get_loader_versions('forge').then(shallowRef).catch(handleError),
get_loader_versions('quilt').then(shallowRef).catch(handleError),
get_loader_versions('neo').then(shallowRef).catch(handleError),
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
ref(
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
)
.catch((err) => {
handleError(err)
return ref([])
}),
])
loaders.value.unshift('vanilla')
const game_versions = computed(() => {
return all_game_versions.value.versions
.filter((item) => {
let defaultVal = item.type === 'release' || showSnapshots.value
if (loader.value === 'fabric') {
defaultVal &= fabric_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'forge') {
defaultVal &= forge_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'quilt') {
defaultVal &= quilt_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'neoforge') {
defaultVal &= neoforge_versions.value.gameVersions.some((x) => item.id === x.id)
}
return defaultVal
})
.map((item) => item.id)
})
const modal = ref(null)
const check_valid = computed(() => {
return (
profile_name.value.trim() &&
game_version.value &&
game_versions.value.includes(game_version.value)
)
})
const create_instance = async () => {
creating.value = true
const loader_version_value =
loader_version.value === 'other' ? specified_loader_version.value : loader_version.value
const loaderVersion = loader.value === 'vanilla' ? null : (loader_version_value ?? 'stable')
hide()
creating.value = false
await create(
profile_name.value,
game_version.value,
loader.value,
loader.value === 'vanilla' ? null : (loader_version_value ?? 'stable'),
icon.value,
).catch(handleError)
trackEvent('InstanceCreate', {
profile_name: profile_name.value,
game_version: game_version.value,
loader: loader.value,
loader_version: loaderVersion,
has_icon: !!icon.value,
source: 'CreationModal',
})
}
const upload_icon = async () => {
const res = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
},
],
})
icon.value = res.path ?? res
if (!icon.value) return
display_icon.value = convertFileSrc(icon.value)
}
const reset_icon = () => {
icon.value = null
display_icon.value = null
}
const selectable_versions = computed(() => {
if (game_version.value) {
if (loader.value === 'fabric') {
return fabric_versions.value.gameVersions[0].loaders.map((item) => item.id)
} else if (loader.value === 'forge') {
return forge_versions.value.gameVersions
.find((item) => item.id === game_version.value)
.loaders.map((item) => item.id)
} else if (loader.value === 'quilt') {
return quilt_versions.value.gameVersions[0].loaders.map((item) => item.id)
} else if (loader.value === 'neoforge') {
return neoforge_versions.value.gameVersions
.find((item) => item.id === game_version.value)
.loaders.map((item) => item.id)
}
}
return []
})
const openFile = async () => {
const newProject = await open({ multiple: false })
if (!newProject) return
hide()
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileOpen',
})
}
const profiles = ref(
new Map([
['MultiMC', []],
['GDLauncher', []],
['ATLauncher', []],
['Curseforge', []],
['PrismLauncher', []],
]),
)
const loading = ref(false)
const importedProfiles = ref(0)
const totalProfiles = ref(0)
const selectedProfileType = ref('MultiMC')
const profileOptions = ref([
{ name: 'MultiMC', path: '' },
{ name: 'GDLauncher', path: '' },
{ name: 'ATLauncher', path: '' },
{ name: 'Curseforge', path: '' },
{ name: 'PrismLauncher', path: '' },
])
// Attempt to get import profiles on default paths
const promises = profileOptions.value.map(async (option) => {
const path = await get_default_launcher_path(option.name).catch(handleError)
if (!path || path === '') return
// Try catch to allow failure and simply ignore default path attempt
try {
const instances = await get_importable_instances(option.name, path)
if (!instances) return
profileOptions.value.find((profile) => profile.name === option.name).path = path
profiles.value.set(
option.name,
instances.map((name) => ({ name, selected: false })),
)
} catch {
// Allow failure silently
}
})
await Promise.all(promises)
const selectLauncherPath = async () => {
selectedProfileType.value.path = await open({ multiple: false, directory: true })
if (selectedProfileType.value.path) {
await reload()
}
}
const reload = async () => {
const instances = await get_importable_instances(
selectedProfileType.value.name,
selectedProfileType.value.path,
).catch(handleError)
if (instances) {
profiles.value.set(
selectedProfileType.value.name,
instances.map((name) => ({ name, selected: false })),
)
} else {
profiles.value.set(selectedProfileType.value.name, [])
}
}
const setPath = () => {
profileOptions.value.find((profile) => profile.name === selectedProfileType.value.name).path =
selectedProfileType.value.path
}
const next = async () => {
importedProfiles.value = 0
totalProfiles.value = Array.from(profiles.value.values())
.map((profiles) => profiles.filter((profile) => profile.selected).length)
.reduce((a, b) => a + b, 0)
loading.value = true
for (const launcher of Array.from(profiles.value.entries()).map(([launcher, profiles]) => ({
launcher,
path: profileOptions.value.find((option) => option.name === launcher).path,
profiles,
}))) {
for (const profile of launcher.profiles.filter((profile) => profile.selected)) {
await import_instance(launcher.launcher, launcher.path, profile.name)
.catch(handleError)
.then(() => console.log(`Successfully Imported ${profile.name} from ${launcher.launcher}`))
profile.selected = false
importedProfiles.value++
}
}
loading.value = false
}
</script>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: var(--gap-md);
margin-top: var(--gap-lg);
}
.input-label {
font-size: 1rem;
font-weight: bolder;
color: var(--color-contrast);
margin-bottom: 0.5rem;
}
.text-input {
width: 20rem;
}
.image-upload {
display: flex;
gap: 1rem;
}
.image-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
}
.warning {
font-style: italic;
}
:deep(button.checkbox) {
border: none;
}
.selector {
max-width: 20rem;
}
.labeled-divider {
text-align: center;
}
.labeled-divider:after {
background-color: var(--color-raised-bg);
content: 'Or';
color: var(--color-base);
padding: var(--gap-sm);
position: relative;
top: -0.5rem;
}
.info {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
}
.modal-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding-bottom: 0;
}
.path-selection {
padding: var(--gap-xl);
background-color: var(--color-bg);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--gap-md);
h3 {
margin: 0;
}
.path-input {
display: flex;
align-items: center;
width: 100%;
flex-direction: row;
gap: var(--gap-sm);
.iconified-input {
flex-grow: 1;
:deep(input) {
width: 100%;
flex-basis: auto;
}
}
}
}
.table {
border: 1px solid var(--color-bg);
}
.table-row {
grid-template-columns: min-content auto;
}
.table-content {
max-height: calc(5 * (18px + 2rem));
height: calc(5 * (18px + 2rem));
overflow-y: auto;
}
.select-checkbox {
button.checkbox {
border: none;
}
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-md);
.transparent {
padding: var(--gap-sm) 0;
}
}
.empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: bolder;
color: var(--color-contrast);
}
.card-divider {
margin: var(--gap-md) var(--gap-lg) 0 var(--gap-lg);
}
</style>

View File

@@ -148,8 +148,9 @@ function startAnimation() {
}
}
onMounted(() => {
onMounted(async () => {
window.addEventListener('resize', pickLink)
await nextTick()
pickLink()
})

View File

@@ -49,26 +49,22 @@ onUnmounted(() => {
</script>
<template>
<NavButton
v-for="instance in recentInstances"
:key="instance.id"
v-tooltip.right="instance.name"
:to="`/instance/${encodeURIComponent(instance.path)}`"
class="relative"
>
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.path"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
<NavButton :to="`/instance/${encodeURIComponent(instance.path)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.path"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
</div>
<div
v-if="instances && recentInstances.length > 0"
class="h-px w-6 mx-auto my-2 bg-divider"

View File

@@ -39,7 +39,8 @@
class="shrink-0 no-wrap"
@click.stop="install()"
>
<template v-if="!installed">
<SpinnerIcon v-if="installing" class="animate-spin" />
<template v-else-if="!installed">
<DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else />
</template>
@@ -60,14 +61,17 @@
</template>
<script setup>
import { CheckIcon, DownloadIcon, PlusIcon } from '@modrinth/assets'
import { CheckIcon, DownloadIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { install as installVersion } from '@/store/install.js'
import { injectContentInstall } from '@/providers/content-install'
const { install: installVersion } = injectContentInstall()
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
@@ -99,6 +103,14 @@ const props = defineProps({
type: String,
default: undefined,
},
activeLoader: {
type: String,
default: null,
},
activeGameVersion: {
type: String,
default: null,
},
})
const emit = defineEmits(['open', 'install'])
@@ -112,13 +124,19 @@ async function install() {
null,
props.instance ? props.instance.path : null,
'SearchCard',
() => {
(versionId) => {
installing.value = false
emit('install', props.project.project_id ?? props.project.id)
if (versionId) {
emit('install', props.project.project_id ?? props.project.id)
}
},
(profile) => {
router.push(`/instance/${profile}`)
},
{
preferredLoader: props.activeLoader ?? undefined,
preferredGameVersion: props.activeGameVersion ?? undefined,
},
).catch(handleError)
}

View File

@@ -6,9 +6,10 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js'
import { install as installVersion } from '@/store/install.js'
import { injectContentInstall } from '@/providers/content-install'
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const confirmModal = ref(null)
const project = ref(null)

View File

@@ -15,7 +15,7 @@ import { open } from '@tauri-apps/plugin-dialog'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
@@ -100,7 +100,8 @@ const addCategory = () => {
watch(
[title, groups, groups],
async () => {
await edit(props.instance.path, editProfileObject.value)
if (removing.value) return
await edit(props.instance.path, editProfileObject.value).catch(handleError)
},
{ deep: true },
)
@@ -108,8 +109,6 @@ watch(
const removing = ref(false)
async function removeProfile() {
removing.value = true
await remove(props.instance.path).catch(handleError)
removing.value = false
trackEvent('InstanceRemove', {
loader: props.instance.loader,
@@ -117,6 +116,7 @@ async function removeProfile() {
})
await router.push({ path: '/' })
await remove(props.instance.path).catch(handleError)
}
const messages = defineMessages({
@@ -194,15 +194,7 @@ const messages = defineMessages({
</script>
<template>
<ConfirmModalWrapper
ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
:show-ad-on-close="false"
@proceed="removeProfile"
/>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeProfile" />
<div class="block">
<div class="float-end ml-4 relative group">
<OverflowMenu

View File

@@ -0,0 +1,78 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<TrashIcon />
{{ formatMessage(messages.deleteButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.confirm-delete.header',
defaultMessage: 'Delete instance',
},
admonitionHeader: {
id: 'app.instance.confirm-delete.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'app.instance.confirm-delete.admonition-body',
defaultMessage:
'All data for your instance will be permanently deleted, including your worlds, configs, and all installed content.',
},
deleteButton: {
id: 'app.instance.confirm-delete.delete-button',
defaultMessage: 'Delete instance',
},
})
const emit = defineEmits<{
(e: 'delete'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('delete')
}
defineExpose({
show,
})
</script>

View File

@@ -1,13 +1,9 @@
<!-- @deprecated Use ConfirmModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { ConfirmModal } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()
const props = defineProps({
defineProps({
confirmationText: {
type: String,
default: '',
@@ -38,6 +34,7 @@ const props = defineProps({
type: Boolean,
default: true,
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
@@ -53,21 +50,13 @@ const modal = useTemplateRef('modal')
defineExpose({
show: () => {
hide_ads_window()
modal.value?.show()
},
hide: () => {
onModalHide()
modal.value?.hide()
},
})
function onModalHide() {
if (props.showAdOnClose) {
show_ads_window()
}
}
function proceed() {
emit('proceed')
}
@@ -82,8 +71,6 @@ function proceed() {
:description="description"
:proceed-icon="proceedIcon"
:proceed-label="proceedLabel"
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
:danger="danger"
:markdown="markdown"
@proceed="proceed"

View File

@@ -73,6 +73,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import type { ContentItem } from '@modrinth/ui'
import {
Admonition,
Avatar,
@@ -88,9 +89,7 @@ import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { installServerProject, useInstall } from '@/store/install.js'
import type { ContentItem } from '../../../../../../packages/ui/src/components/instances/types'
import { injectServerInstall } from '@/providers/server-install'
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
@@ -99,7 +98,7 @@ const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const installStore = useInstall()
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
@@ -125,14 +124,14 @@ async function fetchData(versionId: string) {
async function handleAccept() {
hide()
const serverProjectId = project.value?.id
installStore.startInstallingServer(serverProjectId)
startInstallingServer(serverProjectId)
try {
await installServerProject(serverProjectId)
onInstallComplete.value()
} catch (error) {
console.error('Failed to install server project from InstallToPlayModal:', error)
} finally {
installStore.stopInstallingServer(serverProjectId)
stopInstallingServer(serverProjectId)
}
}

View File

@@ -12,20 +12,22 @@ import {
Avatar,
commonMessages,
defineMessage,
NewModal,
TabbedModal,
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/profile'
import type { InstanceSettingsTabProps } from '../../../helpers/types'
@@ -99,16 +101,31 @@ const tabs = computed<TabbedModalTab<InstanceSettingsTabProps>[]>(() => [
},
])
const queryClient = useQueryClient()
const modal = ref()
const tabbedModal = useTemplateRef('tabbedModal')
function show() {
function show(tabIndex?: number) {
if (props.instance.linked_data?.project_id) {
queryClient.prefetchQuery({
queryKey: ['linkedModpackInfo', props.instance.path],
queryFn: () => get_linked_modpack_info(props.instance.path, 'stale_while_revalidate'),
})
}
modal.value.show()
if (tabIndex !== undefined) {
nextTick(() => tabbedModal.value?.setTab(tabIndex))
}
}
defineExpose({ show })
</script>
<template>
<ModalWrapper ref="modal">
<NewModal
ref="modal"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
>
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
@@ -124,6 +141,7 @@ defineExpose({ show })
</template>
<TabbedModal
ref="tabbedModal"
:tabs="
tabs.map((tab) => ({
...tab,
@@ -135,5 +153,5 @@ defineExpose({ show })
}))
"
/>
</ModalWrapper>
</NewModal>
</template>

View File

@@ -1,12 +1,8 @@
<!-- @deprecated Use NewModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { NewModal as Modal } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()
const props = defineProps({
header: {
type: String,
@@ -26,6 +22,7 @@ const props = defineProps({
return () => {}
},
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
@@ -35,31 +32,21 @@ const modal = useTemplateRef('modal')
defineExpose({
show: (e: MouseEvent) => {
hide_ads_window()
modal.value?.show(e)
},
hide: () => {
onModalHide()
modal.value?.hide()
},
})
function onModalHide() {
if (props.showAdOnClose) {
show_ads_window()
}
props.onHide?.()
}
</script>
<template>
<Modal
ref="modal"
:header="header"
:noblur="!themeStore.advancedRendering"
:closable="closable"
:hide-header="hideHeader"
@hide="onModalHide"
:on-hide="() => props.onHide?.()"
>
<template #title>
<slot name="title" />

View File

@@ -1,12 +1,8 @@
<!-- @deprecated Use ShareModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { ShareModal } from '@modrinth/ui'
import { ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()
defineProps({
header: {
type: String,
@@ -34,18 +30,12 @@ const modal = ref(null)
defineExpose({
show: (passedContent) => {
hide_ads_window()
modal.value.show(passedContent)
},
hide: () => {
onModalHide()
modal.value.hide()
},
})
function onModalHide() {
show_ads_window()
}
</script>
<template>
@@ -56,7 +46,5 @@ function onModalHide() {
:share-text="shareText"
:link="link"
:open-in-new-tab="openInNewTab"
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
/>
</template>

View File

@@ -1,153 +1,39 @@
<template>
<NewModal
ref="modal"
<ContentDiffModal
ref="diffModal"
:header="formatMessage(messages.updateToPlay)"
:closable="true"
no-padding
@hide="() => show_ads_window()"
>
<div v-if="instance" class="max-w-[500px]">
<div class="flex flex-col gap-4 p-4">
<Admonition type="info" :header="formatMessage(messages.updateRequired)">
{{ formatMessage(messages.updateRequiredDescription, { name: instance.name }) }}
</Admonition>
<div v-if="diffs.length" class="flex flex-col gap-2">
<span v-if="publishedDate" class="text-contrast font-semibold">{{
formatDate(publishedDate)
}}</span>
<div class="flex gap-2">
<div v-if="removedCount" class="flex gap-1 items-center">
<MinusIcon />
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
</div>
<div v-if="addedCount" class="flex gap-1 items-center">
<PlusIcon />
{{ formatMessage(messages.addedCount, { count: addedCount }) }}
</div>
<div v-if="updatedCount" class="flex gap-1 items-center">
<RefreshCwIcon />
{{ formatMessage(messages.updatedCount, { count: updatedCount }) }}
</div>
</div>
</div>
</div>
<div
v-if="diffs.length"
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
>
<div
v-for="(diff, index) in diffs"
:key="diff.project_id"
class="grid items-center min-h-10 h-10 gap-2"
:class="diff.project?.title ? 'grid-cols-[auto_1fr_1fr_1fr]' : 'grid-cols-[auto_1fr_1fr]'"
>
<div class="flex flex-col justify-between items-center">
<div class="w-[1px] h-2"></div>
<PlusIcon v-if="diff.type === 'added'" />
<MinusIcon v-else-if="diff.type === 'removed'" />
<RefreshCwIcon v-else />
<div
:class="index === diffs.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
class="w-[1px] h-2 relative top-1"
></div>
</div>
<div class="flex gap-1 col-span-2">
<span class="text-sm">{{ formatMessage(diffTypeMessages[diff.type]) }}</span>
<span
v-if="diff.project"
v-tooltip="diff.project.title"
class="text-sm text-contrast font-medium truncate"
>
{{ diff.project.title }}
</span>
<span
v-else-if="diff.fileName"
v-tooltip="diff.fileName"
class="text-sm text-contrast font-medium truncate"
>
{{ decodeURIComponent(diff.fileName) }}
</span>
</div>
<span
v-if="
diff.project?.title &&
(getFilename(diff.newVersion) || getFilename(diff.currentVersion) || diff.fileName)
"
v-tooltip="
getFilename(diff.newVersion) ||
getFilename(diff.currentVersion) ||
decodeURIComponent(diff.fileName || '')
"
class="text-xs truncate text-right"
>
{{
getFilename(diff.newVersion) ||
getFilename(diff.currentVersion) ||
decodeURIComponent(diff.fileName || '')
}}
</span>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-between gap-2">
<ButtonStyled color="red" type="transparent">
<button @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<div class="flex gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleUpdate">
<DownloadIcon />
{{ formatMessage(commonMessages.updateButton) }}
</button>
</ButtonStyled>
</div>
</div>
</template>
</NewModal>
:admonition-header="formatMessage(messages.updateRequired)"
:description="
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
"
:diffs="normalizedDiffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:show-report-button="true"
@confirm="handleUpdate"
@cancel="handleDecline"
@report="handleReport"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon } from '@modrinth/assets'
import {
DownloadIcon,
MinusIcon,
PlusIcon,
RefreshCwIcon,
ReportIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
NewModal,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { update_managed_modrinth_version } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { useInstall } from '@/store/install.js'
import { injectServerInstall } from '@/providers/server-install'
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
@@ -187,28 +73,26 @@ type ProjectInfo = {
}
const { formatMessage } = useVIntl()
const formatDate = useFormatDateTime({ dateStyle: 'long' })
const installStore = useInstall()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const modal = ref<InstanceType<typeof NewModal>>()
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const removedCount = computed(() => diffs.value.filter((d) => d.type === 'removed').length)
const addedCount = computed(() => diffs.value.filter((d) => d.type === 'added').length)
const updatedCount = computed(() => diffs.value.filter((d) => d.type === 'updated').length)
const publishedDate = computed(() =>
modpackVersion.value?.date_published ? new Date(modpackVersion.value.date_published) : null,
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
type: diff.type,
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
})),
)
function getFilename(version?: Version): string | undefined {
return version?.files.find((f) => f.primary)?.filename
}
async function computeDependencyDiffs(
currentDeps: Dependency[],
latestDeps: Dependency[],
@@ -305,7 +189,7 @@ async function computeDependencyDiffs(
}
})
.sort((a, b) => {
const typeOrder = { removed: 0, added: 1, updated: 2 }
const typeOrder = { added: 0, updated: 1, removed: 2 }
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
if (typeCompare !== 0) return typeCompare
@@ -355,7 +239,7 @@ watch(
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.linked_data?.project_id
if (serverProjectId) installStore.startInstallingServer(serverProjectId)
if (serverProjectId) startInstallingServer(serverProjectId)
try {
if (modpackVersionId.value && instance.value) {
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
@@ -364,7 +248,7 @@ async function handleUpdate() {
} catch (error) {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) installStore.stopInstallingServer(serverProjectId)
if (serverProjectId) stopInstallingServer(serverProjectId)
}
}
@@ -389,12 +273,11 @@ function show(
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
onUpdateComplete.value = callback
hide_ads_window()
modal.value?.show(e)
diffModal.value?.show(e)
}
function hide() {
modal.value?.hide()
diffModal.value?.hide()
}
const messages = defineMessages({
@@ -411,33 +294,6 @@ const messages = defineMessages({
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
},
removedCount: {
id: 'app.modal.update-to-play.removed-count',
defaultMessage: '{count} removed',
},
addedCount: {
id: 'app.modal.update-to-play.added-count',
defaultMessage: '{count} added',
},
updatedCount: {
id: 'app.modal.update-to-play.updated-count',
defaultMessage: '{count} updated',
},
})
const diffTypeMessages = defineMessages({
added: {
id: 'app.modal.update-to-play.diff-type.added',
defaultMessage: 'Added',
},
removed: {
id: 'app.modal.update-to-play.diff-type.removed',
defaultMessage: 'Removed',
},
updated: {
id: 'app.modal.update-to-play.diff-type.updated',
defaultMessage: 'Updated',
},
})
const hasUpdate = computed(() => {