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

@@ -67,10 +67,22 @@ Each project may have its own `CLAUDE.md` with detailed instructions:
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Skills (`.claude/skills/`)
Project-specific skill files with detailed patterns. Use them when the task matches:
- **`api-module`** — Adding a new API endpoint module to `packages/api-client` (types, module class, registry registration)
- **`cross-platform-pages`** — Building a page that needs to work in both the website (`apps/frontend`) and the desktop app (`apps/app-frontend`)
- **`dependency-injection`** — Creating or wiring up a `provide`/`inject` context for platform abstraction or deep component state sharing
- **`figma-mcp`** — Translating a Figma design into Vue components using the Figma MCP tools
- **`i18n-convert`** — Converting hardcoded English strings in Vue SFCs into the `@modrinth/ui` i18n system (`defineMessages`, `formatMessage`, `IntlFormatted`)
- **`multistage-modals`** — Building a wizard-like modal with multiple stages, progress tracking, and per-stage buttons using `MultiStageModal`
- **`tanstack-query`** — Fetching, caching, or mutating server data with `@tanstack/vue-query` (queries, mutations, invalidation, optimistic updates)
## Code Guidelines
### Comments
- DO NOT use "heading" comments like: // === Helper methods === .
- DO NOT use "heading" comments like: `=== Helper methods ===`.
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
## Bash Guidelines
@@ -78,12 +90,30 @@ Each project may have its own `CLAUDE.md` with detailed instructions:
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
- Run commands directly without pipes when possible
- If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- IMPORTANT: Run commands directly without pipes when possible
- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- ALWAYS read the full output — never pipe through filters
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
## Edit Tool - Whitespace Handling (CLAUDE ONLY)
The Read tool uses `→` to mark where line numbers end and file content begins.
**Rule:** Copy the EXACT whitespace that appears after the `→` marker.
- Whatever appears between `→` and the code text is what's actually in the file
- That whitespace must be used EXACTLY in Edit tool's old_string
- Don't count arrows, don't interpret - just copy what's after the `→`
**Example:**
14→ private byte tag;
For Edit, use: ` private byte tag;` (copy everything after →, including the two tabs)
**If Edit fails:** Stop and explain the problem. Do not attempt sed/awk/bash workarounds.
**IMPORTANT**: Trust the Read tool output. Copy what's after `→` into Edit immediately. DO NOT verify with sed/od/grep first - that's wasting time and the instructions already tell you to stop if Edit fails, not to pre-verify.
## Skills

View File

@@ -22,23 +22,24 @@
"@tanstack/vue-query": "^5.90.7",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"intl-messageformat": "^10.7.7",
"vue-i18n": "^10.0.0",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"intl-messageformat": "^10.7.7",
"ofetch": "^1.3.4",
"pinia": "^3.0.0",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-multiselect": "3.0.0",
"vue-router": "^4.6.0",
"vue-virtual-scroller": "v2.0.0-beta.8"

View File

@@ -31,6 +31,8 @@ import {
Button,
ButtonStyled,
commonMessages,
ContentInstallModal,
CreationFlowModal,
defineMessages,
I18nDebugPanel,
NewsArticleCard,
@@ -38,6 +40,7 @@ import {
OverflowMenu,
PopupNotificationPanel,
ProgressSpinner,
provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
providePageContext,
@@ -66,8 +69,6 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
@@ -86,6 +87,7 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { create_profile_and_install_from_file } from '@/helpers/pack'
import { list } from '@/helpers/profile.js'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -98,15 +100,16 @@ import {
isNetworkMetered,
} from '@/helpers/utils.js'
import i18n from '@/i18n.config'
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
provideAppUpdateDownloadProgress,
subscribeToDownloadProgress,
} from '@/providers/download-progress.ts'
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
import { setupProviders } from '@/providers/setup'
import { useError } from '@/store/error.js'
import { playServerProject, useInstall } from '@/store/install.js'
import { useLoading, useTheming } from '@/store/state'
import { create_profile_and_install_from_file } from './helpers/pack'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
@@ -136,6 +139,20 @@ providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
onShow: () => hide_ads_window(),
onHide: () => show_ads_window(),
})
const {
installationModal,
handleCreate,
handleBrowseModpacks,
searchModpacks,
getProjectVersions,
} = setupProviders(notificationManager)
const news = ref([])
const availableSurvey = ref(false)
@@ -392,7 +409,34 @@ const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
const install = useInstall()
const contentInstall = createContentInstall({ router, handleError })
provideContentInstall(contentInstall)
const {
instances: contentInstallInstances,
compatibleLoaders: contentInstallLoaders,
gameVersions: contentInstallGameVersions,
loading: contentInstallLoading,
defaultTab: contentInstallDefaultTab,
preferredLoader: contentInstallPreferredLoader,
preferredGameVersion: contentInstallPreferredGameVersion,
releaseGameVersions: contentInstallReleaseGameVersions,
handleInstallToInstance,
handleCreateAndInstall,
handleNavigate: handleContentInstallNavigate,
handleCancel: handleContentInstallCancel,
setContentInstallModal,
setInstallConfirmModal: setContentInstallConfirmModal,
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
} = contentInstall
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
provideServerInstall(serverInstall)
const {
setInstallToPlayModal: setServerInstallToPlayModal,
setUpdateToPlayModal: setServerUpdateToPlayModal,
setAddServerToInstanceModal: setServerAddServerToInstanceModal,
} = serverInstall
const modInstallModal = ref()
const addServerToInstanceModal = ref()
const installConfirmModal = ref()
@@ -474,13 +518,12 @@ onMounted(() => {
error.setErrorModal(errorModal.value)
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
install.setInstallConfirmModal(installConfirmModal)
install.setModInstallModal(modInstallModal)
install.setAddServerToInstanceModal(addServerToInstanceModal)
install.setInstallToPlayModal(installToPlayModal)
install.setUpdateToPlayModal(updateToPlayModal)
install.setPopupNotificationManager(popupNotificationManager)
setContentIncompatibilityWarningModal(incompatibilityWarningModal.value)
setContentInstallConfirmModal(installConfirmModal.value)
setContentInstallModal(modInstallModal.value)
setServerAddServerToInstanceModal(addServerToInstanceModal.value)
setServerInstallToPlayModal(installToPlayModal.value)
setServerUpdateToPlayModal(updateToPlayModal.value)
})
const accounts = ref(null)
@@ -898,9 +941,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<Suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>
<CreationFlowModal
ref="installationModal"
type="instance"
show-snapshot-toggle
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
@create="handleCreate"
@browse-modpacks="handleBrowseModpacks"
/>
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] w-[--left-bar-width]"
>
@@ -946,7 +995,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</suspense>
<NavButton
v-tooltip.right="'Create new instance'"
:to="() => $refs.installationModal.show()"
:to="() => installationModal?.show()"
:disabled="offline"
>
<PlusIcon />
@@ -1021,9 +1070,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</NavButton>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex p-3">
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div data-tauri-drag-region class="flex items-center gap-1 ml-3">
<div data-tauri-drag-region class="flex min-w-0 flex-1 overflow-hidden p-3">
<ModrinthAppLogo class="h-full w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="flex shrink-0 items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()"
@@ -1039,7 +1088,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
<Breadcrumbs class="pt-[2px]" />
</div>
<section data-tauri-drag-region class="flex ml-auto items-center">
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<ButtonStyled
v-if="!forceSidebar && themeStore.toggleSidebar"
:type="sidebarToggled ? 'standard' : 'transparent'"
@@ -1220,7 +1269,21 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<PopupNotificationPanel has-sidebar />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ModInstallModal ref="modInstallModal" />
<ContentInstallModal
ref="modInstallModal"
:instances="contentInstallInstances"
:compatible-loaders="contentInstallLoaders"
:game-versions="contentInstallGameVersions"
:loading="contentInstallLoading"
:default-tab="contentInstallDefaultTab"
:preferred-loader="contentInstallPreferredLoader"
:preferred-game-version="contentInstallPreferredGameVersion"
:release-game-versions="contentInstallReleaseGameVersions"
@install="handleInstallToInstance"
@create-and-install="handleCreateAndInstall"
@navigate="handleContentInstallNavigate"
@cancel="handleContentInstallCancel"
/>
<AddServerToInstanceModal ref="addServerToInstanceModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<InstallConfirmModal ref="installConfirmModal" />

View File

@@ -22,7 +22,7 @@ import { computed, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { duplicate, remove } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
@@ -302,14 +302,7 @@ const filteredResults = computed(() => {
/>
</section>
</div>
<ConfirmModalWrapper
ref="confirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteProfile"
/>
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>

View File

@@ -19,15 +19,16 @@ import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const router = useRouter()
@@ -238,14 +239,7 @@ onUnmounted(() => {
</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 removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteProfile"
/>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteProfile" />
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">

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)),
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="text-primary"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}
class="shrink-0 whitespace-nowrap text-primary"
>
{{ resolveLabel(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
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" />
{{ 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,13 +49,8 @@ onUnmounted(() => {
</script>
<template>
<NavButton
v-for="instance in recentInstances"
:key="instance.id"
v-tooltip.right="instance.name"
:to="`/instance/${encodeURIComponent(instance.path)}`"
class="relative"
>
<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"
@@ -64,11 +59,12 @@ onUnmounted(() => {
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10"
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
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)
:admonition-header="formatMessage(messages.updateRequired)"
:description="
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
"
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>
: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(() => {

View File

@@ -67,3 +67,17 @@ export async function get_search_results_v3_many(ids, cacheBehaviour) {
export async function purge_cache_types(cacheTypes) {
return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
}
/**
* Get versions for a project (without changelogs for fast loading).
* Uses the cache system - versions are cached for 30 minutes.
* @param {string} projectId - The project ID
* @param {string} [cacheBehaviour] - Cache behaviour ('must_revalidate', etc.)
* @returns {Promise<Array|null>} Array of version objects (without changelogs) or null
*/
export async function get_project_versions(projectId, cacheBehaviour) {
return await invoke('plugin:cache|get_project_versions', {
projectId,
cacheBehaviour,
})
}

View File

@@ -1,65 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
export async function create_profile_and_install(
projectId,
versionId,
packTitle,
iconUrl,
createInstanceCallback = () => {},
) {
const location = {
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title: packTitle,
icon_url: iconUrl,
}
const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
const profile = await create(
profile_creator.name,
profile_creator.gameVersion,
profile_creator.modloader,
profile_creator.loaderVersion,
null,
true,
)
createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile })
}
export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
const location = {
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title,
}
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
}
// Installs pack from a path
export async function create_profile_and_install_from_file(path) {
const location = {
type: 'fromFile',
path: path,
}
const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
const profile = await create(
profile_creator.name,
profile_creator.gameVersion,
profile_creator.modloader,
profile_creator.loaderVersion,
null,
true,
)
return await invoke('plugin:pack|pack_install', { location, profile })
}

View File

@@ -0,0 +1,90 @@
import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
import type { InstanceLoader } from './types'
interface PackProfileCreator {
name: string
gameVersion: string
modloader: InstanceLoader
loaderVersion: string | null
}
interface PackLocationVersionId {
type: 'fromVersionId'
project_id: string
version_id: string
title: string
icon_url?: string
}
interface PackLocationFile {
type: 'fromFile'
path: string
}
export async function create_profile_and_install(
projectId: string,
versionId: string,
packTitle: string,
iconUrl?: string,
createInstanceCallback: (profile: string) => void = () => {},
): Promise<void> {
const location: PackLocationVersionId = {
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title: packTitle,
icon_url: iconUrl,
}
const profile_creator = await invoke<PackProfileCreator>(
'plugin:pack|pack_get_profile_from_pack',
{ location },
)
const profile = await create(
profile_creator.name,
profile_creator.gameVersion,
profile_creator.modloader,
profile_creator.loaderVersion,
null,
true,
)
createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile })
}
export async function install_to_existing_profile(
projectId: string,
versionId: string,
title: string,
profilePath: string,
): Promise<void> {
const location: PackLocationVersionId = {
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title,
}
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
}
export async function create_profile_and_install_from_file(path: string): Promise<void> {
const location: PackLocationFile = {
type: 'fromFile',
path,
}
const profile_creator = await invoke<PackProfileCreator>(
'plugin:pack|pack_get_profile_from_pack',
{ location },
)
const profile = await create(
profile_creator.name,
profile_creator.gameVersion,
profile_creator.modloader,
profile_creator.loaderVersion,
null,
true,
)
return await invoke('plugin:pack|pack_install', { location, profile })
}

View File

@@ -1,216 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack.js'
/// Add instance
/*
name: String, // the name of the profile, and relative path to create
game_version: String, // the game version of the profile
modloader: ModLoader, // the modloader to use
- ModLoader is an enum, with the following variants: Vanilla, Forge, Fabric, Quilt
loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Path, // the icon for the profile
- icon is a path to an image file, which will be copied into the profile directory
*/
export async function create(
name,
gameVersion,
modloader,
loaderVersion,
icon,
skipInstall,
linkedData,
) {
//Trim string name to avoid "Unable to find directory"
name = name.trim()
return await invoke('plugin:profile-create|profile_create', {
name,
gameVersion,
modloader,
loaderVersion,
icon,
skipInstall,
linkedData,
})
}
// duplicate a profile
export async function duplicate(path) {
return await invoke('plugin:profile-create|profile_duplicate', { path })
}
// Remove a profile
export async function remove(path) {
return await invoke('plugin:profile|profile_remove', { path })
}
// Get a profile by path
// Returns a Profile
export async function get(path) {
return await invoke('plugin:profile|profile_get', { path })
}
export async function get_many(paths) {
return await invoke('plugin:profile|profile_get_many', { paths })
}
// Get a profile's projects
// Returns a map of a path to profile file
export async function get_projects(path, cacheBehaviour) {
return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
}
// Get a profile's full fs path
// Returns a path
export async function get_full_path(path) {
return await invoke('plugin:profile|profile_get_full_path', { path })
}
// Get's a mod's full fs path
// Returns a path
export async function get_mod_full_path(path, projectPath) {
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
}
// Get optimal java version from profile
// Returns a java version
export async function get_optimal_jre_key(path) {
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
}
// Get a copy of the profile set
// Returns hashmap of path -> Profile
export async function list() {
return await invoke('plugin:profile|profile_list')
}
export async function check_installed(path, projectId) {
return await invoke('plugin:profile|profile_check_installed', { path, projectId })
}
// Installs/Repairs a profile
export async function install(path, force) {
return await invoke('plugin:profile|profile_install', { path, force })
}
// Updates all of a profile's projects
export async function update_all(path) {
return await invoke('plugin:profile|profile_update_all', { path })
}
// Updates a specified project
export async function update_project(path, projectPath) {
return await invoke('plugin:profile|profile_update_project', { path, projectPath })
}
// Add a project to a profile from a version
// Returns a path to the new project file
export async function add_project_from_version(path, versionId) {
return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
}
// Add a project to a profile from a path + project_type
// Returns a path to the new project file
export async function add_project_from_path(path, projectPath, projectType) {
return await invoke('plugin:profile|profile_add_project_from_path', {
path,
projectPath,
projectType,
})
}
// Toggle disabling a project
export async function toggle_disable_project(path, projectPath) {
return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
}
// Remove a project
export async function remove_project(path, projectPath) {
return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
}
// Update a managed Modrinth profile to a specific version
export async function update_managed_modrinth_version(path, versionId) {
return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
path,
versionId,
})
}
// Repair a managed Modrinth profile
export async function update_repair_modrinth(path) {
return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
}
// Export a profile to .mrpack
/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// Version id is optional (ie: 1.1.5)
export async function export_profile_mrpack(
path,
exportLocation,
includedOverrides,
versionId,
description,
name,
) {
return await invoke('plugin:profile|profile_export_mrpack', {
path,
exportLocation,
includedOverrides,
versionId,
description,
name,
})
}
// Given a folder path, populate an array of all the subfolders
// Intended to be used for finding potential override folders
// profile
// -- mods
// -- resourcepacks
// -- file1
// => [mods, resourcepacks]
// allows selection for 'included_overrides' in export_profile_mrpack
export async function get_pack_export_candidates(profilePath) {
return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
}
// Run Minecraft using a pathed profile
// Returns PID of child
export async function run(path, serverAddress = null) {
return await invoke('plugin:profile|profile_run', { path, serverAddress })
}
export async function kill(path) {
return await invoke('plugin:profile|profile_kill', { path })
}
// Edits a profile
export async function edit(path, editProfile) {
return await invoke('plugin:profile|profile_edit', { path, editProfile })
}
// Edits a profile's icon
export async function edit_icon(path, iconPath) {
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
}
export async function finish_install(instance) {
if (instance.install_stage !== 'pack_installed') {
let linkedData = instance.linked_data
await install_to_existing_profile(
linkedData.project_id,
linkedData.version_id,
instance.name,
instance.path,
)
} else {
await install(instance.path, false)
}
}

View File

@@ -0,0 +1,298 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem, ContentOwner } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack'
import type {
CacheBehaviour,
ContentFile,
ContentFileProjectType,
GameInstance,
InstanceLoader,
} from './types'
// Add instance
/*
name: String, // the name of the profile, and relative path to create
game_version: String, // the game version of the profile
modloader: ModLoader, // the modloader to use
- ModLoader is an enum, with the following variants: Vanilla, Forge, Fabric, Quilt
loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Path, // the icon for the profile
- icon is a path to an image file, which will be copied into the profile directory
*/
export async function create(
name: string,
gameVersion: string,
modloader: InstanceLoader,
loaderVersion: string | null,
icon: string | null,
skipInstall: boolean,
linkedData?: { project_id: string; version_id: string; locked: boolean } | null,
): Promise<string> {
// Trim string name to avoid "Unable to find directory"
name = name.trim()
return await invoke('plugin:profile-create|profile_create', {
name,
gameVersion,
modloader,
loaderVersion,
icon,
skipInstall,
linkedData,
})
}
// duplicate a profile
export async function duplicate(path: string): Promise<string> {
return await invoke('plugin:profile-create|profile_duplicate', { path })
}
// Remove a profile
export async function remove(path: string): Promise<void> {
return await invoke('plugin:profile|profile_remove', { path })
}
// Get a profile by path
// Returns a Profile
export async function get(path: string): Promise<GameInstance | null> {
return await invoke('plugin:profile|profile_get', { path })
}
export async function get_many(paths: string[]): Promise<GameInstance[]> {
return await invoke('plugin:profile|profile_get_many', { paths })
}
// Get a profile's projects
// Returns a map of a path to profile file
export async function get_projects(
path: string,
cacheBehaviour?: CacheBehaviour,
): Promise<Record<string, ContentFile>> {
return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
}
// Get just the installed project IDs for a profile (lightweight, skips update checks)
export async function get_installed_project_ids(path: string): Promise<string[]> {
return await invoke('plugin:profile|profile_get_installed_project_ids', { path })
}
// Get content items with rich metadata for a profile
// Returns content items filtered to exclude modpack files (if linked),
// sorted alphabetically by project name
export async function get_content_items(
path: string,
cacheBehaviour?: CacheBehaviour,
): Promise<ContentItem[]> {
return await invoke('plugin:profile|profile_get_content_items', { path, cacheBehaviour })
}
// Linked modpack info returned from backend
export interface LinkedModpackInfo {
project: Labrinth.Projects.v2.Project
version: Labrinth.Versions.v2.Version
owner: ContentOwner | null
has_update: boolean
update_version_id: string | null
update_version: Labrinth.Versions.v2.Version | null
}
// Get linked modpack info for a profile
// Returns project, version, and owner information for the linked modpack,
// or null if the profile is not linked to a modpack
export async function get_linked_modpack_info(
path: string,
cacheBehaviour?: CacheBehaviour,
): Promise<LinkedModpackInfo | null> {
return await invoke('plugin:profile|profile_get_linked_modpack_info', { path, cacheBehaviour })
}
// Get content items that are part of the linked modpack
// Returns the modpack's dependencies as ContentItem list
// Returns empty array if the profile is not linked to a modpack
export async function get_linked_modpack_content(
path: string,
cacheBehaviour?: CacheBehaviour,
): Promise<ContentItem[]> {
return await invoke('plugin:profile|profile_get_linked_modpack_content', { path, cacheBehaviour })
}
// Convert a list of dependencies into ContentItems with rich metadata
export async function get_dependencies_as_content_items(
dependencies: Labrinth.Versions.v3.Dependency[],
cacheBehaviour?: CacheBehaviour,
): Promise<ContentItem[]> {
return await invoke('plugin:profile|profile_get_dependencies_as_content_items', {
dependencies,
cacheBehaviour,
})
}
// Get a profile's full fs path
// Returns a path
export async function get_full_path(path: string): Promise<string> {
return await invoke('plugin:profile|profile_get_full_path', { path })
}
// Get's a mod's full fs path
// Returns a path
export async function get_mod_full_path(path: string, projectPath: string): Promise<string> {
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
}
// Get optimal java version from profile
// Returns a java version
export async function get_optimal_jre_key(path: string): Promise<string | null> {
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
}
// Get a copy of the profile set
// Returns hashmap of path -> Profile
export async function list(): Promise<GameInstance[]> {
return await invoke('plugin:profile|profile_list')
}
export async function check_installed(path: string, projectId: string): Promise<boolean> {
return await invoke('plugin:profile|profile_check_installed', { path, projectId })
}
export async function check_installed_batch(projectId: string): Promise<Record<string, boolean>> {
return await invoke('plugin:profile|profile_check_installed_batch', { projectId })
}
// Installs/Repairs a profile
export async function install(path: string, force: boolean): Promise<void> {
return await invoke('plugin:profile|profile_install', { path, force })
}
// Updates all of a profile's projects
export async function update_all(path: string): Promise<Record<string, string>> {
return await invoke('plugin:profile|profile_update_all', { path })
}
// Updates a specified project
export async function update_project(path: string, projectPath: string): Promise<string> {
return await invoke('plugin:profile|profile_update_project', { path, projectPath })
}
// Add a project to a profile from a version
// Returns a path to the new project file
export async function add_project_from_version(path: string, versionId: string): Promise<string> {
return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
}
// Add a project to a profile from a path + project_type
// Returns a path to the new project file
export async function add_project_from_path(
path: string,
projectPath: string,
projectType?: ContentFileProjectType,
): Promise<string> {
return await invoke('plugin:profile|profile_add_project_from_path', {
path,
projectPath,
projectType,
})
}
// Toggle disabling a project
export async function toggle_disable_project(path: string, projectPath: string): Promise<string> {
return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
}
// Remove a project
export async function remove_project(path: string, projectPath: string): Promise<void> {
return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
}
// Update a managed Modrinth profile to a specific version
export async function update_managed_modrinth_version(
path: string,
versionId: string,
): Promise<void> {
return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
path,
versionId,
})
}
// Repair a managed Modrinth profile
export async function update_repair_modrinth(path: string): Promise<void> {
return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
}
// Export a profile to .mrpack
// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// Version id is optional (ie: 1.1.5)
export async function export_profile_mrpack(
path: string,
exportLocation: string,
includedOverrides: string[],
versionId?: string,
description?: string,
name?: string,
): Promise<void> {
return await invoke('plugin:profile|profile_export_mrpack', {
path,
exportLocation,
includedOverrides,
versionId,
description,
name,
})
}
// Given a folder path, populate an array of all the subfolders
// Intended to be used for finding potential override folders
// profile
// -- mods
// -- resourcepacks
// -- file1
// => [mods, resourcepacks]
// allows selection for 'included_overrides' in export_profile_mrpack
export async function get_pack_export_candidates(profilePath: string): Promise<string[]> {
return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
}
// Run Minecraft using a pathed profile
// Returns PID of child
export async function run(path: string, serverAddress: string | null = null): Promise<unknown> {
return await invoke('plugin:profile|profile_run', { path, serverAddress })
}
export async function kill(path: string): Promise<void> {
return await invoke('plugin:profile|profile_kill', { path })
}
// Edits a profile
export async function edit(path: string, editProfile: Partial<GameInstance>): Promise<void> {
return await invoke('plugin:profile|profile_edit', { path, editProfile })
}
// Edits a profile's icon
export async function edit_icon(path: string, iconPath: string | null): Promise<void> {
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
}
export async function finish_install(instance: GameInstance): Promise<void> {
if (instance.install_stage !== 'pack_installed') {
const linkedData = instance.linked_data
if (linkedData) {
await install_to_existing_profile(
linkedData.project_id,
linkedData.version_id,
instance.name,
instance.path,
)
}
} else {
await install(instance.path, false)
}
}

View File

@@ -49,17 +49,10 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
hash: string
file_name: string
size: number
metadata?: FileMetadata
update_version_id?: string
project_type: ContentFileProjectType
}
type FileMetadata = {
metadata?: {
project_id: string
version_id: string
}
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'

View File

@@ -5,6 +5,78 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
"app.export-modal.description-placeholder": {
"message": "Enter modpack description..."
},
"app.export-modal.export-button": {
"message": "Export"
},
"app.export-modal.header": {
"message": "Export modpack"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Name"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack name"
},
"app.export-modal.select-files-label": {
"message": "Select files and folders to include in pack"
},
"app.export-modal.version-number-label": {
"message": "Version number"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
"app.instance.confirm-delete.admonition-header": {
"message": "This action cannot be undone"
},
"app.instance.confirm-delete.delete-button": {
"message": "Delete instance"
},
"app.instance.confirm-delete.header": {
"message": "Delete instance"
},
"app.instance.mods.content-type-project": {
"message": "project"
},
"app.instance.mods.copy-link": {
"message": "Copy link"
},
"app.instance.mods.installing": {
"message": "Installing..."
},
"app.instance.mods.modpack-fallback": {
"message": "Modpack"
},
"app.instance.mods.project-was-added": {
"message": "\"{name}\" was added"
},
"app.instance.mods.projects-were-added": {
"message": "{count} projects were added"
},
"app.instance.mods.share-text": {
"message": "Check out the projects I'm using in my modpack!"
},
"app.instance.mods.share-title": {
"message": "Sharing modpack content"
},
"app.instance.mods.show-file": {
"message": "Show file"
},
"app.instance.mods.successfully-uploaded": {
"message": "Successfully uploaded"
},
"app.instance.mods.unknown-version": {
"message": "Unknown"
},
"app.instance.mods.updating": {
"message": "Updating..."
},
"app.modal.install-to-play.content-required": {
"message": "Content required"
},
@@ -32,33 +104,15 @@
"app.modal.install-to-play.view-contents": {
"message": "View contents"
},
"app.modal.update-to-play.added-count": {
"message": "{count} added"
},
"app.modal.update-to-play.diff-type.added": {
"message": "Added"
},
"app.modal.update-to-play.diff-type.removed": {
"message": "Removed"
},
"app.modal.update-to-play.diff-type.updated": {
"message": "Updated"
},
"app.modal.update-to-play.header": {
"message": "Update to play"
},
"app.modal.update-to-play.removed-count": {
"message": "{count} removed"
},
"app.modal.update-to-play.update-required": {
"message": "Update required"
},
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
},
"app.modal.update-to-play.updated-count": {
"message": "{count} updated"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
@@ -227,12 +281,6 @@
"instance.edit-world.title": {
"message": "Edit world"
},
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
"instance.server-modal.address": {
"message": "Address"
},
@@ -341,150 +389,9 @@
"instance.settings.tabs.installation": {
"message": "Installation"
},
"instance.settings.tabs.installation.change-version.already-installed.modded": {
"message": "{platform} {version} for Minecraft {game_version} already installed"
},
"instance.settings.tabs.installation.change-version.already-installed.vanilla": {
"message": "Vanilla {game_version} already installed"
},
"instance.settings.tabs.installation.change-version.button": {
"message": "Change version"
},
"instance.settings.tabs.installation.change-version.button.install": {
"message": "Install"
},
"instance.settings.tabs.installation.change-version.button.installing": {
"message": "Installing"
},
"instance.settings.tabs.installation.change-version.cannot-while-fetching": {
"message": "Fetching modpack versions"
},
"instance.settings.tabs.installation.change-version.in-progress": {
"message": "Installing new version"
},
"instance.settings.tabs.installation.currently-installed": {
"message": "Currently installed"
},
"instance.settings.tabs.installation.debug-information": {
"message": "Debug information:"
},
"instance.settings.tabs.installation.fetching-modpack-details": {
"message": "Fetching modpack details"
},
"instance.settings.tabs.installation.game-version": {
"message": "Game version"
},
"instance.settings.tabs.installation.install": {
"message": "Install"
},
"instance.settings.tabs.installation.install.in-progress": {
"message": "Installation in progress"
},
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} version"
},
"instance.settings.tabs.installation.minecraft-version": {
"message": "Minecraft {version}"
},
"instance.settings.tabs.installation.no-connection": {
"message": "Cannot fetch linked modpack details. Please check your internet connection."
},
"instance.settings.tabs.installation.no-loader-versions": {
"message": "{loader} is not available for Minecraft {version}. Try another mod loader."
},
"instance.settings.tabs.installation.no-modpack-found": {
"message": "This instance is linked to a modpack, but the modpack could not be found on Modrinth."
},
"instance.settings.tabs.installation.platform": {
"message": "Platform"
},
"instance.settings.tabs.installation.reinstall.button": {
"message": "Reinstall modpack"
},
"instance.settings.tabs.installation.reinstall.button.reinstalling": {
"message": "Reinstalling modpack"
},
"instance.settings.tabs.installation.reinstall.confirm.description": {
"message": "Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation. This may fix unexpected behavior if changes have been made to the instance, but if your worlds now depend on additional installed content, it may break existing worlds."
},
"instance.settings.tabs.installation.reinstall.confirm.title": {
"message": "Are you sure you want to reinstall this instance?"
},
"instance.settings.tabs.installation.reinstall.description": {
"message": "Resets the instance's content to its original state, removing any mods or content you have added on top of the original modpack."
},
"instance.settings.tabs.installation.reinstall.title": {
"message": "Reinstall modpack"
},
"instance.settings.tabs.installation.repair.button": {
"message": "Repair"
},
"instance.settings.tabs.installation.repair.button.repairing": {
"message": "Repairing"
},
"instance.settings.tabs.installation.repair.confirm.description": {
"message": "Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods."
},
"instance.settings.tabs.installation.repair.confirm.title": {
"message": "Repair instance?"
},
"instance.settings.tabs.installation.repair.in-progress": {
"message": "Repair in progress"
},
"instance.settings.tabs.installation.reset-selections": {
"message": "Reset to current"
},
"instance.settings.tabs.installation.show-all-versions": {
"message": "Show all versions"
},
"instance.settings.tabs.installation.tooltip.action.change-version": {
"message": "change version"
},
"instance.settings.tabs.installation.tooltip.action.install": {
"message": "install"
},
"instance.settings.tabs.installation.tooltip.action.reinstall": {
"message": "reinstall"
},
"instance.settings.tabs.installation.tooltip.action.repair": {
"message": "repair"
},
"instance.settings.tabs.installation.tooltip.cannot-while-installing": {
"message": "Cannot {action} while installing"
},
"instance.settings.tabs.installation.tooltip.cannot-while-offline": {
"message": "Cannot {action} while offline"
},
"instance.settings.tabs.installation.tooltip.cannot-while-repairing": {
"message": "Cannot {action} while repairing"
},
"instance.settings.tabs.installation.unknown-version": {
"message": "(unknown version)"
},
"instance.settings.tabs.installation.unlink-server-vanilla.description": {
"message": "This instance is linked to a server, which means you can't change the Minecraft version. Unlinking will permanently disconnect this instance from the server."
},
"instance.settings.tabs.installation.unlink-server.description": {
"message": "This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server."
},
"instance.settings.tabs.installation.unlink-server.title": {
"message": "Unlink from server"
},
"instance.settings.tabs.installation.unlink.button": {
"message": "Unlink instance"
},
"instance.settings.tabs.installation.unlink.confirm.description": {
"message": "If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal instance."
},
"instance.settings.tabs.installation.unlink.confirm.title": {
"message": "Are you sure you want to unlink this instance?"
},
"instance.settings.tabs.installation.unlink.description": {
"message": "This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack."
},
"instance.settings.tabs.installation.unlink.title": {
"message": "Unlink from modpack"
},
"instance.settings.tabs.java": {
"message": "Java and memory"
},

View File

@@ -42,6 +42,10 @@ app.use(FloatingVue, {
instantMove: true,
distance: 8,
},
'dismissable-prompt': {
$extend: 'dropdown',
placement: 'bottom-start',
},
},
})
app.use(i18nPlugin)

View File

@@ -24,6 +24,7 @@ import {
SearchFilterControl,
SearchSidebarFilter,
StyledInput,
useDebugLogger,
useSearch,
useServerSearch,
useVIntl,
@@ -44,27 +45,32 @@ import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import {
get as getInstance,
get_projects as getInstanceProjects,
get_installed_project_ids as getInstalledProjectIds,
kill,
list as listInstances,
} from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { getServerLatency } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { getServerAddress, playServerProject, useInstall } from '@/store/install.js'
import { getServerAddress } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const installStore = useInstall()
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
injectServerInstall()
const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
const projectTypes = computed(() => {
debugLog('projectTypes computed', route.params.projectType)
return [route.params.projectType as ProjectType]
})
debugLog('fetching tags (categories, loaders, gameVersions)')
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
.catch(handleError)
@@ -97,61 +103,62 @@ type Instance = {
}
}
type InstanceProject = {
metadata: {
project_id: string
}
}
const instance: Ref<Instance | null> = ref(null)
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const isServerInstance = ref(false)
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
await updateInstanceContext()
await initInstanceContext()
watch(
() => [route.query.i, route.query.ai, route.path],
() => {
updateInstanceContext()
},
)
async function updateInstanceContext() {
async function initInstanceContext() {
debugLog('initInstanceContext', { queryI: route.query.i, queryAi: route.query.ai })
if (route.query.i) {
;[instance.value, instanceProjects.value] = await Promise.all([
getInstance(route.query.i).catch(handleError),
getInstanceProjects(route.query.i).catch(handleError),
])
newlyInstalled.value = []
instance.value = await getInstance(route.query.i).catch(handleError)
debugLog('instance loaded', {
name: instance.value?.name,
loader: instance.value?.loader,
gameVersion: instance.value?.game_version,
})
// Load installed project IDs in background — the page and initial search render immediately.
// When this resolves, instanceFilters recomputes and triggers a search refresh
// that applies the "hide installed" negative filters and marks installed badges.
getInstalledProjectIds(route.query.i)
.then((ids) => {
debugLog('installedProjectIds loaded', { count: ids?.length })
installedProjectIds.value = ids
})
.catch(handleError)
isServerInstance.value = false
if (instance.value?.linked_data?.project_id) {
debugLog('checking linked project for server status', instance.value.linked_data.project_id)
const projectV3 = await get_project_v3(
instance.value.linked_data.project_id,
'must_revalidate',
).catch(handleError)
if (projectV3?.minecraft_server != null) {
debugLog('instance is a server instance')
isServerInstance.value = true
}
}
}
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
debugLog('setting instanceHideInstalled from query', route.query.ai)
instanceHideInstalled.value = route.query.ai === 'true'
}
if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
instance.value = null
instanceHideInstalled.value = false
}
}
const instanceFilters = computed(() => {
const filters = []
debugLog('instanceFilters recomputing', {
hasInstance: !!instance.value,
isServer: isServerInstance.value,
hideInstalled: instanceHideInstalled.value,
})
if (instance.value) {
const gameVersion = instance.value.game_version
@@ -179,24 +186,9 @@ const instanceFilters = computed(() => {
option: 'client',
})
}
if (instanceHideInstalled.value && instanceProjects.value) {
const installedMods = Object.values(instanceProjects.value)
.filter((x) => x.metadata)
.map((x) => x.metadata.project_id)
installedMods.push(...newlyInstalled.value)
installedMods
?.map((x) => ({
type: 'project_id',
option: `project_id:${x}`,
negative: true,
}))
.forEach((x) => filters.push(x))
}
}
debugLog('instanceFilters result', filters)
return filters
})
@@ -221,11 +213,25 @@ const {
createPageParams,
} = useSearch(projectTypes, tags, instanceFilters)
const activeLoader = computed(() => {
const filter = currentFilters.value.find((f) => f.type === 'mod_loader')
if (filter) return filter.option
if (projectType.value === 'datapack' || projectType.value === 'resourcepack') return 'vanilla'
return instance.value?.loader ?? null
})
const activeGameVersion = computed(() => {
const filter = currentFilters.value.find((f) => f.type === 'game_version')
if (filter) return filter.option
return instance.value?.game_version ?? null
})
const serverHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const serverPings = shallowRef<Record<string, number | undefined>>({})
const runningServerProjects = ref<Record<string, string>>({})
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('checkServerRunningStates', { hitCount: hits.length })
const packs = await listInstances()
const newRunning: Record<string, string> = {}
for (const hit of hits) {
@@ -237,10 +243,12 @@ async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchPro
}
}
}
debugLog('runningServerProjects updated', newRunning)
runningServerProjects.value = newRunning
}
async function handleStopServerProject(projectId: string) {
debugLog('handleStopServerProject', projectId)
const instancePath = runningServerProjects.value[projectId]
if (!instancePath) return
await kill(instancePath).catch(() => {})
@@ -249,18 +257,21 @@ async function handleStopServerProject(projectId: string) {
}
async function handlePlayServerProject(projectId: string) {
debugLog('handlePlayServerProject', projectId)
await playServerProject(projectId)
checkServerRunningStates(serverHits.value)
}
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name })
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
installStore.showAddServerToInstanceModal(project.name, address)
showAddServerToInstanceModal(project.name, address)
}
const unlistenProcesses = await process_listener(
(e: { event: string; profile_path_id: string }) => {
debugLog('process event', e)
if (e.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
([, path]) => path === e.profile_path_id,
@@ -288,6 +299,7 @@ const {
} = useServerSearch({ tags, query, maxResults, currentPage })
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('pingServerHits', { hitCount: hits.length })
const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address)
await Promise.all(
pingsToFetch.map(async (hit) => {
@@ -303,13 +315,15 @@ async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
}
const previousFilterState = ref('')
const isRefreshing = ref(false)
let searchVersion = 0
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
debugLog('went offline')
offline.value = true
})
window.addEventListener('online', () => {
debugLog('went online')
offline.value = false
})
@@ -334,29 +348,51 @@ const pageCount = computed(() =>
)
const effectiveRequestParams = computed(() => {
return projectType.value === 'server' ? serverRequestParams.value : requestParams.value
const isServer = projectType.value === 'server'
debugLog('effectiveRequestParams computed', { isServer })
return isServer ? serverRequestParams.value : requestParams.value
})
watch(effectiveRequestParams, async () => {
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(effectiveRequestParams, () => {
if (!route.params.projectType) return
await nextTick()
debugLog('effectiveRequestParams changed, debouncing search')
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
refreshSearch()
}, 200)
})
watch(instanceHideInstalled, () => {
debugLog('instanceHideInstalled changed', instanceHideInstalled.value)
refreshSearch()
})
async function refreshSearch() {
if (isRefreshing.value) return
isRefreshing.value = true
const version = ++searchVersion
debugLog('refreshSearch start', { version, projectType: projectType.value })
try {
const isServer = projectType.value === 'server'
if (isServer) {
debugLog('searching v3 (server)', serverRequestParams.value)
const rawResults = (await get_search_results_v3(serverRequestParams.value)) as {
result: Labrinth.Search.v3.SearchResults
} | null
if (version !== searchVersion) {
debugLog('search version stale, discarding', { version, current: searchVersion })
return
}
const searchResults = rawResults?.result ?? { hits: [], total_hits: 0 }
const hits = searchResults.hits ?? []
debugLog('server search results', {
hitCount: hits.length,
totalHits: searchResults.total_hits,
})
serverHits.value = hits
serverPings.value = {}
pingServerHits(hits)
@@ -368,10 +404,16 @@ async function refreshSearch() {
offset: 0,
}
} else {
debugLog('searching v2', requestParams.value)
let rawResults = (await get_search_results(requestParams.value)) as {
result: SearchResults
} | null
if (version !== searchVersion) {
debugLog('search version stale, discarding', { version, current: searchVersion })
return
}
if (!rawResults) {
rawResults = {
result: {
@@ -383,18 +425,24 @@ async function refreshSearch() {
}
}
if (instance.value) {
const installedProjectIds = new Set([
const allInstalledIds = new Set([
...newlyInstalled.value,
...Object.values(instanceProjects.value ?? {})
.filter((x) => x.metadata)
.map((x) => x.metadata.project_id),
...(installedProjectIds.value ?? []),
])
rawResults.result.hits = rawResults.result.hits.map((val) => ({
...val,
installed: installedProjectIds.has(val.project_id),
installed: allInstalledIds.has(val.project_id),
}))
if (instanceHideInstalled.value) {
rawResults.result.hits = rawResults.result.hits.filter((val) => !val.installed)
}
}
debugLog('v2 search results', {
hitCount: rawResults.result.hits.length,
totalHits: rawResults.result.total_hits,
})
results.value = rawResults.result
}
@@ -407,6 +455,7 @@ async function refreshSearch() {
})
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
debugLog('filters changed, resetting to page 1')
currentPage.value = 1
}
@@ -445,16 +494,21 @@ async function refreshSearch() {
})
.join('&')
const newUrl = `${route.path}${queryString ? '?' + queryString : ''}`
debugLog('updating URL', newUrl)
window.history.replaceState(window.history.state, '', newUrl)
} catch (err) {
console.error('Error refreshing search:', err)
} finally {
loading.value = false
isRefreshing.value = false
debugLog('refreshSearch complete', { version })
} catch (err) {
debugLog('refreshSearch error', err)
if (version === searchVersion) {
loading.value = false
}
}
}
async function setPage(newPageNumber: number) {
debugLog('setPage', newPageNumber)
currentPage.value = newPageNumber
await onSearchChangeToTop()
@@ -469,6 +523,7 @@ async function onSearchChangeToTop() {
}
function clearSearch() {
debugLog('clearSearch')
query.value = ''
currentPage.value = 1
}
@@ -479,6 +534,7 @@ watch(
// Check if the newType is not the same as the current value
if (!newType || newType === projectType.value) return
debugLog('projectType route param changed', { from: projectType.value, to: newType })
projectType.value = newType
currentSortType.value = { display: 'Relevance', name: 'relevance' }
@@ -495,7 +551,8 @@ const selectableProjectTypes = computed(() => {
if (
availableGameVersions.value &&
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
availableGameVersions.value.findIndex((x) => x.version === '1.13') &&
!isServerInstance.value
) {
dataPacks = true
}
@@ -624,6 +681,7 @@ const handleOptionsClick = (args) => {
}
}
debugLog('performing initial search')
await refreshSearch()
// Initialize previousFilterState after first search
@@ -854,18 +912,12 @@ previousFilterState.value = JSON.stringify({
</ButtonStyled>
<ButtonStyled v-else color="brand" type="outlined">
<button
:disabled="
(installStore.installingServerProjects as string[]).includes(
project.project_id,
)
"
:disabled="(installingServerProjects as string[]).includes(project.project_id)"
@click="() => handlePlayServerProject(project.project_id)"
>
<PlayIcon />
{{
(installStore.installingServerProjects as string[]).includes(
project.project_id,
)
(installingServerProjects as string[]).includes(project.project_id)
? 'Installing...'
: 'Play'
}}
@@ -882,6 +934,19 @@ previousFilterState.value = JSON.stringify({
:project-type="projectType"
:project="result"
:instance="instance ?? undefined"
:active-loader="activeLoader ?? undefined"
:active-game-version="activeGameVersion ?? undefined"
:categories="[
...(categories ?? []).filter(
(cat) =>
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
),
...(loaders ?? []).filter(
(loader) =>
result?.display_categories.includes(loader.name) &&
loader.supported_project_types?.includes(projectType),
),
]"
:installed="result.installed || newlyInstalled.includes(result.project_id || '')"
@install="
(id) => {

View File

@@ -110,9 +110,13 @@
<div class="flex gap-2">
<ButtonStyled
v-if="
['installing', 'pack_installing', 'minecraft_installing'].includes(
instance.install_stage,
)
[
'installing',
'pack_installing',
'pack_installed',
'not_installed',
'minecraft_installing',
].includes(instance.install_stage)
"
color="brand"
size="large"
@@ -245,6 +249,7 @@
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
:open-settings="() => settingsModal?.show(1)"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
@@ -334,14 +339,15 @@ import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
import { get_server_status } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { playServerProject } from '@/store/install.js'
import { useBreadcrumbs, useLoading } from '@/store/state'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const route = useRoute()
const router = useRouter()
@@ -607,6 +613,10 @@ const unlistenProfiles = await profile_listener(
return
}
instance.value = await get(route.params.id as string).catch(handleError)
if (!instance.value?.linked_data?.project_id) {
linkedProjectV3.value = undefined
isServerInstance.value = false
}
}
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -176,13 +176,11 @@ import {
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds.ts'
import {
ensureManagedServerWorldExists,
getServerAddress,
playServerProject,
} from '@/store/install'
import { injectServerInstall } from '@/providers/server-install'
import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install'
const { handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const route = useRoute()
const addServerModal = ref<InstanceType<typeof AddServerModal>>()

View File

@@ -1,17 +1,17 @@
<script setup>
import { PlusIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { onUnmounted, ref, shallowRef } from 'vue'
import { inject, onUnmounted, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile.js'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
const { handleError } = injectNotificationManager()
const showCreationModal = inject('showCreationModal')
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
@@ -55,11 +55,10 @@ onUnmounted(() => {
<NewInstanceImage />
</div>
<h3>No instances found</h3>
<Button color="primary" :disabled="offline" @click="$refs.installationModal.show()">
<Button color="primary" :disabled="offline" @click="showCreationModal?.()">
<PlusIcon />
Create new instance
</Button>
<InstanceCreationModal ref="installationModal" />
</div>
</div>
</template>

View File

@@ -69,15 +69,11 @@
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<button
:disabled="data && installStore.installingServerProjects.includes(data.id)"
:disabled="data && installingServerProjects.includes(data.id)"
@click="handleClickPlay"
>
<PlayIcon />
{{
data && installStore.installingServerProjects.includes(data.id)
? 'Installing...'
: 'Play'
}}
{{ data && installingServerProjects.includes(data.id) ? 'Installing...' : 'Play' }}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
@@ -264,24 +260,23 @@ import {
} from '@/helpers/profile'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerLatency } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import {
getServerAddress,
install as installVersion,
playServerProject,
useInstall,
} from '@/store/install.js'
import { getServerAddress } from '@/store/install.js'
import { useTheming } from '@/store/state.js'
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const installStore = useInstall()
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
injectServerInstall()
const installing = ref(false)
const data = shallowRef(null)
const versions = shallowRef([])
@@ -355,7 +350,7 @@ async function handleStopServer() {
function handleAddServerToInstance() {
const address = getServerAddress(projectV3.value?.minecraft_java_server)
if (!address || !data.value) return
installStore.showAddServerToInstanceModal(data.value.title, address)
showAddServerToInstanceModal(data.value.title, address)
}
async function fetchProjectData() {

View File

@@ -0,0 +1,515 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentInstallInstance, ContentItem } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { nextTick, type Ref, ref } from 'vue'
import type { Router } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3_many, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack'
import {
add_project_from_version,
check_installed_batch,
create,
get,
get_projects,
list,
remove_project,
} from '@/helpers/profile.js'
import { get_game_versions } from '@/helpers/tags'
import {
findPreferredVersion,
installVersionDependencies,
isVersionCompatible,
} from '@/store/install.js'
interface ModalRef {
show: () => void
hide: () => void
}
interface InstallConfirmModalRef {
show: (
project: Labrinth.Projects.v2.Project,
version: string,
callback: (versionId?: string) => void,
createInstanceCallback: (profile: string) => void,
) => void
}
interface IncompatibilityWarningModalRef {
show: (
instance: GameInstance,
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
callback: (versionId?: string) => void,
) => void
}
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
function sortLoaders(loaders: string[]): string[] {
return loaders.slice().sort((a, b) => {
const aIdx = LOADER_ORDER.indexOf(a)
const bIdx = LOADER_ORDER.indexOf(b)
if (aIdx === -1 && bIdx === -1) return a.localeCompare(b)
if (aIdx === -1) return 1
if (bIdx === -1) return -1
return aIdx - bIdx
})
}
export interface ContentInstallContext {
instances: Ref<ContentInstallInstance[]>
compatibleLoaders: Ref<string[]>
gameVersions: Ref<string[]>
loading: Ref<boolean>
defaultTab: Ref<'existing' | 'new'>
preferredLoader: Ref<string | null>
preferredGameVersion: Ref<string | null>
releaseGameVersions: Ref<Set<string>>
handleInstallToInstance: (instance: ContentInstallInstance) => Promise<void>
handleCreateAndInstall: (data: {
name: string
iconPath: string | null
iconPreviewUrl: string | null
loader: string
gameVersion: string
}) => Promise<void>
handleNavigate: (instance: ContentInstallInstance) => void
handleCancel: () => void
setContentInstallModal: (ref: ModalRef) => void
setInstallConfirmModal: (ref: InstallConfirmModalRef) => void
setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void
install: (
projectId: string,
versionId?: string | null,
instancePath?: string | null,
source?: string,
callback?: (versionId?: string) => void,
createInstanceCallback?: (profile: string) => void,
hints?: { preferredLoader?: string; preferredGameVersion?: string },
) => Promise<void>
installingItems: Ref<Map<string, ContentItem[]>>
}
export const [injectContentInstall, provideContentInstall] = createContext<ContentInstallContext>(
'root',
'contentInstall',
)
export function createContentInstall(opts: {
router: Router
handleError: (err: unknown) => void
}): ContentInstallContext {
const instances = ref<ContentInstallInstance[]>([])
const compatibleLoaders = ref<string[]>([])
const gameVersions = ref<string[]>([])
const loading = ref(false)
const defaultTab = ref<'existing' | 'new'>('existing')
const preferredLoader = ref<string | null>(null)
const preferredGameVersion = ref<string | null>(null)
const releaseGameVersions = ref<Set<string>>(new Set())
const installingItems = ref<Map<string, ContentItem[]>>(new Map())
function addInstallingItem(
instancePath: string,
project: {
id: string
slug?: string | null
title: string
icon_url?: string | null
project_type?: string
},
) {
const placeholder: ContentItem = {
file_name: `__installing_${project.id}`,
project: {
id: project.id,
slug: project.slug ?? null,
title: project.title,
icon_url: project.icon_url ?? null,
},
project_type: project.project_type ?? 'mod',
has_update: false,
update_version_id: null,
enabled: true,
installing: true,
}
const next = new Map(installingItems.value)
const items = next.get(instancePath) ?? []
if (items.some((i) => i.file_name === placeholder.file_name)) return
next.set(instancePath, [...items, placeholder])
installingItems.value = next
}
function removeInstallingItems(instancePath: string, projectIds: string[]) {
const next = new Map(installingItems.value)
const items = next.get(instancePath)
if (items) {
const idsToRemove = new Set(projectIds.map((id) => `__installing_${id}`))
const filtered = items.filter((i) => !idsToRemove.has(i.file_name))
if (filtered.length > 0) {
next.set(instancePath, filtered)
} else {
next.delete(instancePath)
}
installingItems.value = next
}
}
let modalRef: ModalRef | null = null
let installConfirmModalRef: InstallConfirmModalRef | null = null
let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null
let currentProject: Labrinth.Projects.v2.Project | null = null
let currentVersions: Labrinth.Versions.v2.Version[] = []
let currentCallback: (versionId?: string) => void = () => {}
let profileMap: Record<string, GameInstance> = {}
async function showModInstallModal(
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
onInstall: (versionId?: string) => void,
hints?: { preferredLoader?: string; preferredGameVersion?: string },
) {
currentProject = project
currentVersions = versions
currentCallback = onInstall
instances.value = []
defaultTab.value = 'existing'
const loaderSet = new Set<string>()
const gameVersionSet = new Set<string>()
for (const v of versions) {
for (const l of v.loaders) loaderSet.add(l)
for (const gv of v.game_versions) gameVersionSet.add(gv)
}
const mappedLoaders = new Set<string>()
for (const l of loaderSet) {
if (SUPPORTED_LOADERS.has(l)) mappedLoaders.add(l)
else if (VANILLA_COMPATIBLE_LOADERS.has(l)) mappedLoaders.add('vanilla')
}
compatibleLoaders.value = sortLoaders([...mappedLoaders])
try {
const allGameVersions = await get_game_versions()
const releases = new Set<string>()
const ordered: string[] = []
for (const gv of allGameVersions) {
if (gameVersionSet.has(gv.version)) {
ordered.push(gv.version)
if (gv.version_type === 'release') {
releases.add(gv.version)
}
}
}
gameVersions.value = ordered
releaseGameVersions.value = releases
} catch {
gameVersions.value = [...gameVersionSet]
releaseGameVersions.value = new Set(gameVersionSet)
}
preferredLoader.value =
hints?.preferredLoader && loaderSet.has(hints.preferredLoader) ? hints.preferredLoader : null
preferredGameVersion.value =
hints?.preferredGameVersion && gameVersionSet.has(hints.preferredGameVersion)
? hints.preferredGameVersion
: null
try {
let profiles = await list()
const linkedProjectIds = profiles
.filter((p) => p.linked_data?.project_id)
.map((p) => p.linked_data!.project_id)
if (linkedProjectIds.length > 0) {
const linkedProjects = await get_project_v3_many(linkedProjectIds, 'must_revalidate').catch(
() => [],
)
const serverProjectIds = new Set(
linkedProjects
.filter((p: { id: string; minecraft_server?: unknown }) => p?.minecraft_server != null)
.map((p: { id: string }) => p.id),
)
profiles = profiles.filter(
(p) => !p.linked_data?.project_id || !serverProjectIds.has(p.linked_data.project_id),
)
}
const newProfileMap: Record<string, GameInstance> = {}
const installedMap = await check_installed_batch(project.id)
const newInstances: ContentInstallInstance[] = profiles.map((profile) => {
newProfileMap[profile.path] = profile
return {
id: profile.path,
name: profile.name,
iconUrl: profile.icon_path ? convertFileSrc(profile.icon_path) : null,
installed: installedMap[profile.path] ?? false,
compatible: versions.some((v) => isVersionCompatible(v, project, profile)),
installing: false,
}
})
profileMap = newProfileMap
instances.value = newInstances
if (!newInstances.some((i) => i.compatible && !i.installed)) {
defaultTab.value = 'new'
}
} catch (err) {
opts.handleError(err)
}
await nextTick()
modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
}
async function handleInstallToInstance(instance: ContentInstallInstance) {
const profile = profileMap[instance.id]
const storeInstance = instances.value.find((i) => i.id === instance.id)
if (storeInstance) storeInstance.installing = true
const version = findPreferredVersion(currentVersions, currentProject, profile)
if (!version) {
if (storeInstance) storeInstance.installing = false
opts.handleError('No compatible version found')
return
}
const installedProjectIds: string[] = []
if (currentProject) {
addInstallingItem(instance.id, currentProject)
installedProjectIds.push(currentProject.id)
}
try {
await add_project_from_version(instance.id, version.id)
await installVersionDependencies(
profile,
version,
(depProject: Labrinth.Projects.v2.Project) => {
addInstallingItem(instance.id, depProject)
installedProjectIds.push(depProject.id)
},
)
if (storeInstance) {
storeInstance.installed = true
storeInstance.installing = false
}
trackEvent('ProjectInstall', {
loader: profile.loader,
game_version: profile.game_version,
id: currentProject.id,
version_id: version.id,
project_type: currentProject.project_type,
title: currentProject.title,
source: 'ProjectInstallModal',
})
currentCallback(version.id)
} catch (err) {
if (storeInstance) storeInstance.installing = false
opts.handleError(err)
} finally {
removeInstallingItems(instance.id, installedProjectIds)
}
}
async function handleCreateAndInstall(data: {
name: string
iconPath: string | null
iconPreviewUrl: string | null
loader: string
gameVersion: string
}) {
const loaderCandidates =
data.loader === 'vanilla' ? ['vanilla', 'datapack', 'minecraft'] : [data.loader]
const version =
currentVersions.find(
(v) =>
v.game_versions.includes(data.gameVersion) &&
loaderCandidates.some((l) => v.loaders.includes(l)),
) ?? currentVersions[0]
try {
const id = await create(
data.name,
data.gameVersion,
data.loader as InstanceLoader,
'latest',
data.iconPath,
false,
)
if (!id) return
await add_project_from_version(id, version.id)
await opts.router.push(`/instance/${encodeURIComponent(id)}/`)
const instance = await get(id)
await installVersionDependencies(instance, version)
trackEvent('InstanceCreate', {
source: 'ProjectInstallModal',
})
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject.id,
version_id: version.id,
project_type: currentProject.project_type,
title: currentProject.title,
source: 'ProjectInstallModal',
})
currentCallback(version.id)
modalRef?.hide()
} catch (err) {
opts.handleError(err)
}
}
function handleNavigate(instance: ContentInstallInstance) {
modalRef?.hide()
opts.router.push(`/instance/${encodeURIComponent(instance.id)}/`)
}
function handleCancel() {
currentCallback?.()
}
async function install(
projectId: string,
versionId?: string | null,
instancePath?: string | null,
source: string = 'unknown',
callback: (versionId?: string) => void = () => {},
createInstanceCallback: (profile: string) => void = () => {},
hints?: { preferredLoader?: string; preferredGameVersion?: string },
) {
const project: Labrinth.Projects.v2.Project = await get_project(projectId, 'must_revalidate')
if (project.project_type === 'modpack') {
const version = versionId ?? project.versions[project.versions.length - 1]
const packs = await list()
if (
packs.length === 0 ||
!packs.find((pack) => pack.linked_data?.project_id === project.id)
) {
await packInstall(
project.id,
version,
project.title,
project.icon_url,
createInstanceCallback,
)
trackEvent('PackInstall', {
id: project.id,
version_id: version,
title: project.title,
source,
})
callback(version)
} else {
installConfirmModalRef?.show(project, version, callback, createInstanceCallback)
}
} else if (instancePath) {
const [instanceOrNull, instanceProjects, versions] = await Promise.all([
get(instancePath),
get_projects(instancePath),
get_version_many(project.versions, 'must_revalidate') as Promise<
Labrinth.Versions.v2.Version[]
>,
])
if (!instanceOrNull) return
const instance = instanceOrNull
const projectVersions = versions.sort(
(a, b) => dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
let version = versionId
? projectVersions.find((v) => v.id === versionId)
: findPreferredVersion(projectVersions, project, instance)
if (!version) version = projectVersions[0]
if (isVersionCompatible(version, project, instance)) {
for (const [path, file] of Object.entries(instanceProjects)) {
if (file.metadata?.project_id === project.id) {
await remove_project(instance.path, path)
}
}
const installedProjectIds: string[] = [project.id]
addInstallingItem(instancePath, project)
try {
await add_project_from_version(instance.path, version.id)
await installVersionDependencies(
instance,
version,
(depProject: Labrinth.Projects.v2.Project) => {
addInstallingItem(instancePath, depProject)
installedProjectIds.push(depProject.id)
},
)
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.id,
project_type: project.project_type,
version_id: version.id,
title: project.title,
source,
})
callback(version.id)
} finally {
removeInstallingItems(instancePath, installedProjectIds)
}
} else {
incompatibilityWarningModalRef?.show(instance, project, projectVersions, version, callback)
}
} else {
let versions = (
(await get_version_many(project.versions)) as Labrinth.Versions.v2.Version[]
).sort((a, b) => dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf())
if (versionId) versions = versions.filter((v) => v.id === versionId)
await showModInstallModal(project, versions, callback, hints)
}
}
return {
instances,
compatibleLoaders,
gameVersions,
loading,
defaultTab,
preferredLoader,
preferredGameVersion,
releaseGameVersions,
handleInstallToInstance,
handleCreateAndInstall,
handleNavigate,
handleCancel,
setContentInstallModal(ref: ModalRef) {
modalRef = ref
},
setInstallConfirmModal(ref: InstallConfirmModalRef) {
installConfirmModalRef = ref
},
setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) {
incompatibilityWarningModalRef = ref
},
install,
installingItems,
}
}

View File

@@ -0,0 +1,368 @@
import type { Labrinth } from '@modrinth/api-client'
import type { AbstractPopupNotificationManager } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
import { type Ref, ref } from 'vue'
import type { Router } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3, get_version } from '@/helpers/cache.js'
import { install_to_existing_profile } from '@/helpers/pack.js'
import { create, edit, edit_icon, get, install as installProfile, list } from '@/helpers/profile.js'
import type { GameInstance } from '@/helpers/types'
import { start_join_server } from '@/helpers/worlds.ts'
import { handleSevereError } from '@/store/error.js'
import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install.js'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface ModalRef<TShow extends (...args: any[]) => void = () => void> {
show: TShow
hide: () => void
}
export interface ServerInstallContext {
installingServerProjects: Ref<string[]>
startInstallingServer: (projectId: string) => void
stopInstallingServer: (projectId: string) => void
isServerInstalling: (projectId: string) => boolean
installServerProject: (serverProjectId: string) => Promise<void>
playServerProject: (projectId: string) => Promise<void>
setInstallToPlayModal: (
ref: ModalRef<
(
project: Labrinth.Projects.v3.Project,
modpackVersionId: string | null,
callback?: () => void,
) => void
>,
) => void
setUpdateToPlayModal: (
ref: ModalRef<
(instance: GameInstance, activeVersionId: string | null, callback?: () => void) => void
>,
) => void
setAddServerToInstanceModal: (
ref: ModalRef<(serverName: string, serverAddress: string) => void>,
) => void
showAddServerToInstanceModal: (serverName: string, serverAddress: string) => void
}
export const [injectServerInstall, provideServerInstall] = createContext<ServerInstallContext>(
'root',
'serverInstall',
)
export function createServerInstall(opts: {
router: Router
handleError: (err: unknown) => void
popupNotificationManager: AbstractPopupNotificationManager
}): ServerInstallContext {
const installingServerProjects = ref<string[]>([])
let installToPlayModalRef: ModalRef<
(
project: Labrinth.Projects.v3.Project,
modpackVersionId: string | null,
callback?: () => void,
) => void
> | null = null
let updateToPlayModalRef: ModalRef<
(instance: GameInstance, activeVersionId: string | null, callback?: () => void) => void
> | null = null
let addServerToInstanceModalRef: ModalRef<
(serverName: string, serverAddress: string) => void
> | null = null
function startInstallingServer(projectId: string) {
if (!installingServerProjects.value.includes(projectId)) {
installingServerProjects.value.push(projectId)
}
}
function stopInstallingServer(projectId: string) {
installingServerProjects.value = installingServerProjects.value.filter((id) => id !== projectId)
}
function isServerInstalling(projectId: string) {
return installingServerProjects.value.includes(projectId)
}
async function joinServer(profilePath: string, serverAddress: string | null) {
if (!serverAddress) return
await start_join_server(profilePath, serverAddress)
}
async function findInstalledInstance(projectId: string) {
const packs = await list()
return packs.find((pack) => pack.linked_data?.project_id === projectId) ?? null
}
async function createVanillaInstance(
project: Labrinth.Projects.v2.Project,
gameVersion: string,
serverAddress: string | null,
) {
const profilePath = await create(
project.title,
gameVersion,
'vanilla',
null,
project.icon_url ?? null,
false,
{
project_id: project.id,
version_id: '',
locked: true,
},
)
await ensureManagedServerWorldExists(profilePath, project.title, serverAddress)
return profilePath
}
async function updateVanillaGameVersion(instance: GameInstance, targetGameVersion: string) {
if (instance.game_version === targetGameVersion) return
await edit(instance.path, { game_version: targetGameVersion })
await installProfile(instance.path, false)
}
function showModpackInstallSuccess(project: GameInstance, serverAddress: string | null) {
opts.popupNotificationManager.addPopupNotification({
title: 'Install complete',
text: `${project.name} is installed and ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
await joinServer(project.path, serverAddress)
trackEvent('InstanceStart', {
loader: project.loader,
game_version: project.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { profilePath: project.path })
}
},
color: 'brand' as const,
},
]
: []),
{
label: 'Instance',
action: () => opts.router.push(`/instance/${encodeURIComponent(project.path)}`),
},
],
autoCloseMs: null,
})
}
function showUpdateSuccess(instance: GameInstance, serverAddress: string | null) {
opts.popupNotificationManager.addPopupNotification({
title: 'Update complete',
text: `${instance.name} has been updated and is ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
if (serverAddress) await start_join_server(instance.path, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
},
color: 'brand' as const,
},
]
: []),
{
label: 'Instance',
action: () => opts.router.push(`/instance/${encodeURIComponent(instance.path)}`),
},
],
autoCloseMs: null,
})
}
/**
* Server projects that use modpack content have linked_data.project_id as
* the server project id and linked_data.version_id as the modpack content version id.
* The modpack content version can be of the same server project, or from a different project.
*/
async function installServerProject(serverProjectId: string) {
const [project, projectV3] = await Promise.all([
get_project(serverProjectId, 'bypass'),
get_project_v3(serverProjectId, 'bypass'),
])
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const content = projectV3?.minecraft_java_server?.content
if (!content || content.kind !== 'modpack') return
const contentVersionId = content.version_id
const contentVersion = await get_version(contentVersionId, 'bypass')
const contentProjectId = contentVersion.project_id
const gameVersion = contentVersion.game_versions?.[0] ?? ''
const profilePath = await create(
project.title,
gameVersion,
'vanilla',
null,
project.icon_url,
true,
{
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
)
// Save the icon path before pack install overwrites it
const profileBeforeInstall = await get(profilePath)
const originalIconPath = profileBeforeInstall?.icon_path ?? null
await install_to_existing_profile(
contentProjectId,
contentVersionId,
project.title,
profilePath,
)
// Pack install overwrites name, icon, and linked_data with the content project's values.
// Restore them to point to the server project.
await edit(profilePath, {
name: project.title,
linked_data: {
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
})
await edit_icon(profilePath, originalIconPath)
await ensureManagedServerWorldExists(profilePath, project.title, serverAddress)
}
/**
* Handles logic when clicking "Play" on a server project. This includes:
* - Checking if need to install modpack content. If so, opens install to play modal
* - Checking if need to update modpack content. If so, open update to play modal
* - Checking if need to create instance for vanilla server. If so, creates instance.
* - Adding server to worlds list if not already there
* - Joining server
*/
async function playServerProject(projectId: string) {
const [project, projectV3] = await Promise.all([
get_project(projectId, 'bypass'),
get_project_v3(projectId, 'bypass'),
])
if (projectV3?.minecraft_server == null) {
console.warn('playServerProject failed: project is not a server project')
return
}
const content = projectV3?.minecraft_java_server?.content
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const isVanilla = content?.kind === 'vanilla'
const isModpack = content?.kind === 'modpack'
const modpackVersionId = content?.version_id ?? null
const recommendedGameVersion = content?.recommended_game_version
let instance = await findInstalledInstance(project.id)
if (isVanilla && !instance) {
if (installingServerProjects.value.includes(projectId)) return
startInstallingServer(projectId)
try {
const path = await createVanillaInstance(project, recommendedGameVersion, serverAddress)
if (path) {
instance = await get(path)
if (instance) showModpackInstallSuccess(instance, serverAddress)
}
} finally {
stopInstallingServer(projectId)
}
return
}
if (isModpack && !instance) {
installToPlayModalRef?.show(projectV3, modpackVersionId, async () => {
const newInstance = await findInstalledInstance(project.id)
if (!newInstance) return
showModpackInstallSuccess(newInstance, serverAddress)
})
return
}
if (!instance) return
await ensureManagedServerWorldExists(instance.path, project.title, serverAddress)
// Update existing instance if needed
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {
updateToPlayModalRef?.show(instance, modpackVersionId, () => {
showUpdateSuccess(instance, serverAddress)
})
return
}
if (isVanilla && instance.game_version !== recommendedGameVersion) {
if (installingServerProjects.value.includes(projectId)) return
startInstallingServer(projectId)
try {
await updateVanillaGameVersion(instance, recommendedGameVersion)
showUpdateSuccess(instance, serverAddress)
} finally {
stopInstallingServer(projectId)
}
return
}
// Join server
try {
await joinServer(instance.path, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
}
return {
installingServerProjects,
startInstallingServer,
stopInstallingServer,
isServerInstalling,
installServerProject,
playServerProject,
setInstallToPlayModal(ref) {
installToPlayModalRef = ref
},
setUpdateToPlayModal(ref) {
updateToPlayModalRef = ref
},
setAddServerToInstanceModal(ref) {
addServerToInstanceModalRef = ref
},
showAddServerToInstanceModal(serverName: string, serverAddress: string) {
addServerToInstanceModalRef?.show(serverName, serverAddress)
},
}
}

View File

@@ -0,0 +1,16 @@
import type { AbstractWebNotificationManager } from '@modrinth/ui'
import { setupCreationModal } from './setup/creation-modal'
import { setupFilePickerProvider } from './setup/file-picker'
import { setupInstanceImportProvider } from './setup/instance-import'
import { setupTagsProvider } from './setup/tags'
export function setupProviders(notificationManager: AbstractWebNotificationManager) {
setupTagsProvider(notificationManager)
setupFilePickerProvider()
setupInstanceImportProvider(notificationManager)
return {
...setupCreationModal(notificationManager),
}
}

View File

@@ -0,0 +1,110 @@
import type { AbstractWebNotificationManager, CreationFlowContextValue } from '@modrinth/ui'
import { provide, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { import_instance } from '@/helpers/import.js'
import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack'
import { create, list } from '@/helpers/profile.js'
export function setupCreationModal(notificationManager: AbstractWebNotificationManager) {
const { handleError } = notificationManager
const router = useRouter()
const installationModal = useTemplateRef('installationModal')
provide('showCreationModal', async () => {
const instances = await list().catch(handleError)
installationModal.value?.show(instances?.length ?? 0)
})
async function handleCreate(config: CreationFlowContextValue) {
installationModal.value?.hide()
try {
if (config.isImportMode.value) {
for (const [launcherName, instanceSet] of Object.entries(
config.importSelectedInstances.value,
)) {
const launcher = config.importLaunchers.value.find((l) => l.name === launcherName)
if (!launcher || instanceSet.size === 0) continue
for (const name of instanceSet) {
await import_instance(launcher.name, launcher.path, name).catch(handleError)
}
}
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
return
}
if (config.modpackSelection.value) {
const { projectId, versionId, name, iconUrl } = config.modpackSelection.value
await create_profile_and_install(projectId, versionId, name, iconUrl).catch(handleError)
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
return
}
if (config.modpackFilePath.value) {
await create_profile_and_install_from_file(config.modpackFilePath.value).catch(handleError)
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
return
}
// Custom/vanilla setup
const loader = config.hideLoaderChips.value
? 'vanilla'
: (config.selectedLoader.value ?? 'vanilla')
const loaderVersion = config.hideLoaderVersion.value
? null
: (config.selectedLoaderVersion.value ?? config.loaderVersionType.value)
const iconPath = config.instanceIconPath.value ?? null
await create(
config.instanceName.value,
config.selectedGameVersion.value,
loader,
loaderVersion,
iconPath,
false,
).catch(handleError)
trackEvent('InstanceCreate', {
profile_name: config.instanceName.value,
game_version: config.selectedGameVersion.value,
loader,
loader_version: loaderVersion,
has_icon: !!iconPath,
source: 'CreationModal',
})
} catch (err) {
handleError(err)
}
}
function handleBrowseModpacks() {
installationModal.value?.hide()
router.push('/browse/modpack')
}
async function searchModpacks(query: string, limit: number = 10) {
const params = [`facets=[["project_type:modpack"]]`, `limit=${limit}`]
if (query) {
params.push(`query=${encodeURIComponent(query)}`)
}
const raw = await get_search_results(`?${params.join('&')}`)
if (raw?.result) return raw.result
return { hits: [], offset: 0, limit, total_hits: 0 }
}
async function getProjectVersions(projectId: string) {
const versions = await get_project_versions(projectId)
return versions ?? []
}
return {
installationModal,
handleCreate,
handleBrowseModpacks,
searchModpacks,
getProjectVersions,
}
}

View File

@@ -0,0 +1,32 @@
import { provideFilePicker } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
export function setupFilePickerProvider() {
provideFilePicker({
async pickImage() {
const result = await open({
multiple: false,
filters: [{ name: 'Image', extensions: ['png', 'jpeg', 'jpg', 'svg', 'webp', 'gif'] }],
})
if (!result) return null
const path = result.path ?? result
if (!path) return null
const name = path.split(/[\\/]/).pop() || 'icon'
const file = new File([], name)
return { file, path, previewUrl: convertFileSrc(path) }
},
async pickModpackFile() {
const result = await open({
multiple: false,
filters: [{ name: 'Modpack', extensions: ['mrpack'] }],
})
if (!result) return null
const path = result.path ?? result
if (!path) return null
const name = path.split(/[\\/]/).pop() || 'modpack.mrpack'
const file = new File([], name)
return { file, path, previewUrl: '' }
},
})
}

View File

@@ -0,0 +1,47 @@
import type { AbstractWebNotificationManager } from '@modrinth/ui'
import { provideInstanceImport } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import {
get_default_launcher_path,
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
export function setupInstanceImportProvider(notificationManager: AbstractWebNotificationManager) {
const { handleError } = notificationManager
provideInstanceImport({
async getDetectedLaunchers() {
const launcherNames = ['MultiMC', 'GDLauncher', 'ATLauncher', 'Curseforge', 'PrismLauncher']
const launchers = []
for (const name of launcherNames) {
try {
const path = await get_default_launcher_path(name)
if (!path) continue
const instances = await get_importable_instances(name, path)
if (instances?.length > 0) {
launchers.push({ name, path, instances })
}
} catch {
// Skip launchers that fail detection
}
}
return launchers
},
async getImportableInstances(launcherName: string, path: string) {
return (await get_importable_instances(launcherName, path)) ?? []
},
async importInstances(selections) {
for (const sel of selections) {
for (const instanceName of sel.instanceNames) {
await import_instance(sel.launcher, sel.path, instanceName).catch(handleError)
}
}
},
async selectDirectory() {
const result = await open({ multiple: false, directory: true })
return result?.toString() ?? null
},
})
}

View File

@@ -0,0 +1,24 @@
import type { AbstractWebNotificationManager } from '@modrinth/ui'
import { provideTags } from '@modrinth/ui'
import { ref } from 'vue'
import { get_game_versions, get_loaders } from '@/helpers/tags'
export function setupTagsProvider(notificationManager: AbstractWebNotificationManager) {
const { handleError } = notificationManager
const gameVersions = ref([])
const loaders = ref([])
get_game_versions()
.then((v) => {
gameVersions.value = v
})
.catch(handleError)
get_loaders()
.then((v) => {
loaders.value = v
})
.catch(handleError)
provideTags({ gameVersions, loaders })
}

View File

@@ -1,97 +1,14 @@
import dayjs from 'dayjs'
import { defineStore } from 'pinia'
// TODO: migrate to content-install.ts DI
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3, get_version, get_version_many } from '@/helpers/cache.js'
import {
create_profile_and_install as packInstall,
install_to_existing_profile,
} from '@/helpers/pack.js'
import {
add_project_from_version,
check_installed,
create,
edit,
edit_icon,
get,
get_projects,
install as installProfile,
list,
remove_project,
} from '@/helpers/profile.js'
import dayjs from 'dayjs'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { add_project_from_version, check_installed } from '@/helpers/profile.js'
import {
add_server_to_profile,
get_profile_worlds,
resolveManagedServerWorld,
start_join_server,
} from '@/helpers/worlds.ts'
import router from '@/routes.js'
import { handleSevereError } from '@/store/error.js'
export const useInstall = defineStore('installStore', {
state: () => ({
installConfirmModal: null,
modInstallModal: null,
incompatibilityWarningModal: null,
installToPlayModal: null,
updateToPlayModal: null,
popupNotificationManager: null,
installingServerProjects: [],
addServerToInstanceModal: null,
}),
actions: {
setInstallConfirmModal(ref) {
this.installConfirmModal = ref
},
showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) {
this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback)
},
setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref
},
showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) {
this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall)
},
setModInstallModal(ref) {
this.modInstallModal = ref
},
showModInstallModal(project, versions, onInstall) {
this.modInstallModal.show(project, versions, onInstall)
},
setInstallToPlayModal(ref) {
this.installToPlayModal = ref
},
showInstallToPlayModal(projectV3, modpackVersionId, onInstallComplete) {
this.installToPlayModal.show(projectV3, modpackVersionId, onInstallComplete)
},
setUpdateToPlayModal(ref) {
this.updateToPlayModal = ref
},
showUpdateToPlayModal(instance, activeVersionId, onUpdateComplete) {
this.updateToPlayModal.show(instance, activeVersionId, onUpdateComplete)
},
setPopupNotificationManager(manager) {
this.popupNotificationManager = manager
},
setAddServerToInstanceModal(ref) {
this.addServerToInstanceModal = ref
},
showAddServerToInstanceModal(serverName, serverAddress) {
this.addServerToInstanceModal.show(serverName, serverAddress)
},
startInstallingServer(projectId) {
if (!this.installingServerProjects.includes(projectId)) {
this.installingServerProjects.push(projectId)
}
},
stopInstallingServer(projectId) {
this.installingServerProjects = this.installingServerProjects.filter((id) => id !== projectId)
},
isServerInstalling(projectId) {
return this.installingServerProjects.includes(projectId)
},
},
})
export const findPreferredVersion = (versions, project, instance) => {
// When `project` is passed in from this stack trace:
@@ -131,136 +48,23 @@ export const isVersionCompatible = (version, project, instance) => {
)
}
export const install = async (
projectId,
versionId,
instancePath,
source,
callback = () => {},
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'bypass')
const projectV3 = await get_project_v3(projectId, 'bypass')
if (project.project_type === 'modpack' || projectV3?.minecraft_server != null) {
const version = versionId ?? project.versions[project.versions.length - 1]
const packs = await list()
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
await packInstall(
project.id,
version,
project.title,
project.icon_url,
createInstanceCallback,
)
trackEvent('PackInstall', {
id: project.id,
version_id: version,
title: project.title,
source,
})
callback(version)
} else {
const install = useInstall()
install.showInstallConfirmModal(project, version, callback, createInstanceCallback)
}
} else {
if (instancePath) {
const [instance, instanceProjects, versions] = await Promise.all([
await get(instancePath),
await get_projects(instancePath),
await get_version_many(project.versions, 'bypass'),
])
const projectVersions = versions.sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
let version
if (versionId) {
version = projectVersions.find((v) => v.id === versionId)
} else {
version = findPreferredVersion(projectVersions, project, instance)
}
if (!version) {
version = projectVersions[0]
}
if (isVersionCompatible(version, project, instance, true)) {
for (const [path, file] of Object.entries(instanceProjects)) {
if (file.metadata && file.metadata.project_id === project.id) {
await remove_project(instance.path, path)
}
}
await add_project_from_version(instance.path, version.id)
await installVersionDependencies(instance, version)
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.id,
project_type: project.project_type,
version_id: version.id,
title: project.title,
source,
})
callback(version.id)
} else {
const install = useInstall()
install.showIncompatibilityWarningModal(
instance,
project,
projectVersions,
version,
callback,
)
}
} else {
let versions = (await get_version_many(project.versions)).sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
if (versionId) {
versions = versions.filter((v) => v.id === versionId)
}
const install = useInstall()
install.showModInstallModal(project, versions, callback)
}
}
// If project is modpack:
// - We check all available instances if modpack is already installed
// If true: show confirmation modal
// If false: install it (latest version if passed version is null)
// If project is mod:
// - If instance is selected:
// - If project is already installed
// We first uninstall the project
// - If no version is selected, we look check the instance for versions to select based on the versions
// - If there are no versions, we show the incompat modal
// - If a version is selected, and the version is incompatible, we show the incompat modal
// - Version is installed, as well as version dependencies
}
export const installVersionDependencies = async (profile, version) => {
export const installVersionDependencies = async (profile, version, onDepInstalling) => {
for (const dep of version.dependencies) {
if (dep.dependency_type !== 'required') continue
// disallow fabric api install on quilt
if (dep.project_id === 'P7dR8mSH' && profile.loader === 'quilt') continue
if (dep.version_id) {
if (dep.project_id && (await check_installed(profile.path, dep.project_id))) continue
if (dep.project_id && onDepInstalling) {
const depProject = await get_project(dep.project_id, 'bypass').catch(() => null)
if (depProject) onDepInstalling(depProject)
}
await add_project_from_version(profile.path, dep.version_id)
} else {
if (dep.project_id && (await check_installed(profile.path, dep.project_id))) continue
const depProject = await get_project(dep.project_id, 'bypass')
if (onDepInstalling) onDepInstalling(depProject)
const depVersions = (await get_version_many(depProject.versions, 'bypass')).sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
@@ -274,63 +78,6 @@ export const installVersionDependencies = async (profile, version) => {
}
}
/**
* Server projects that use modpack content use have linked_data.project_id as
* the server project id and linked_data.version_id as the modpack content version id
*
* The modpack content version can be of the same server project, or from a different project
*/
export const installServerProject = async (serverProjectId) => {
const [project, projectV3] = await Promise.all([
get_project(serverProjectId, 'bypass'),
get_project_v3(serverProjectId, 'bypass'),
])
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const content = projectV3?.minecraft_java_server?.content
if (!content || content.kind !== 'modpack') return
const contentVersionId = content.version_id
const contentVersion = await get_version(contentVersionId, 'bypass')
const contentProjectId = contentVersion.project_id
const gameVersion = contentVersion.game_versions?.[0] ?? ''
const profilePath = await create(
project.title,
gameVersion,
'vanilla',
null,
project.icon_url,
true,
{
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
)
// Save the icon path before pack install overwrites it
const profileBeforeInstall = await get(profilePath)
const originalIconPath = profileBeforeInstall?.icon_path ?? null
await install_to_existing_profile(contentProjectId, contentVersionId, project.title, profilePath)
// Pack install overwrites name, icon, and linked_data with the content project's values.
// Restore them to point to the server project.
await edit(profilePath, {
name: project.title,
linked_data: {
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
})
await edit_icon(profilePath, originalIconPath)
await ensureManagedServerWorldExists(profilePath, project.title, serverAddress)
}
export const getServerAddress = (javaServer) => {
if (!javaServer) return null
const { address } = javaServer
@@ -349,203 +96,3 @@ export const ensureManagedServerWorldExists = async (profilePath, serverName, se
console.error('Failed to ensure managed server world exists:', err)
}
}
const joinServer = async (profilePath, serverAddress) => {
if (!serverAddress) return
await start_join_server(profilePath, serverAddress)
}
const findInstalledInstance = async (projectId) => {
const packs = await list()
return packs.find((pack) => pack.linked_data?.project_id === projectId) ?? null
}
const createVanillaServerInstance = async (project, gameVersion, serverAddress) => {
const profilePath = await create(
project.title,
gameVersion,
'fabric',
'latest',
project.icon_url,
false,
{
project_id: project.id,
version_id: '',
locked: true,
},
)
await ensureManagedServerWorldExists(profilePath, project.title, serverAddress)
return profilePath
}
const updateVanillaGameVersion = async (instance, targetGameVersion) => {
if (instance.game_version === targetGameVersion) return
await edit(instance.path, { game_version: targetGameVersion })
await installProfile(instance.path, false)
}
const showModpackInstallSuccess = (installStore, project, serverAddress) => {
installStore.popupNotificationManager?.addPopupNotification({
title: 'Install complete',
text: `${project.name} is installed and ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
await joinServer(
project.path,
serverAddress,
project.linked_data?.project_id ?? null,
)
trackEvent('InstanceStart', {
loader: project.loader,
game_version: project.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { profilePath: project.path })
}
},
color: 'brand',
},
]
: []),
{
label: 'Instance',
action: () => router.push(`/instance/${encodeURIComponent(project.path)}`),
},
],
autoCloseMs: null,
})
}
const showUpdateSuccess = (installStore, instance, serverAddress) => {
installStore.popupNotificationManager?.addPopupNotification({
title: 'Update complete',
text: `${instance.name} has been updated and is ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
if (serverAddress) await start_join_server(instance.path, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
},
color: 'brand',
},
]
: []),
{
label: 'Instance',
action: () => router.push(`/instance/${encodeURIComponent(instance.path)}`),
},
],
autoCloseMs: null,
})
}
/**
* Handles logic when clicking "Play" on a server project. This includes:
* - Checking if need to install modpack content. If so, opens install to play modal
* - Checking if need to update modpack content. If so, open update to play modal
* - Checking if need to create instance for vanilla server. If so, creates instance.
* - Adding server to worlds list if not already there
* - Joining server
*/
export const playServerProject = async (projectId) => {
const installStore = useInstall()
const [project, projectV3] = await Promise.all([
get_project(projectId, 'bypass'),
get_project_v3(projectId, 'bypass'),
])
if (projectV3?.minecraft_server == null) {
console.warn('playServerProject failed: project is not a server project')
return
}
const content = projectV3?.minecraft_java_server?.content
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const isVanilla = content?.kind === 'vanilla'
const isModpack = content?.kind === 'modpack'
const modpackVersionId = content?.version_id ?? null
const recommendedGameVersion = content?.recommended_game_version
let instance = await findInstalledInstance(project.id)
if (isVanilla && !instance) {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
const path = await createVanillaServerInstance(project, recommendedGameVersion, serverAddress)
if (path) {
instance = await get(path)
showModpackInstallSuccess(installStore, instance, serverAddress)
}
} finally {
installStore.stopInstallingServer(projectId)
}
return
}
if (isModpack && !instance) {
installStore.showInstallToPlayModal(projectV3, modpackVersionId, async () => {
const newInstance = await findInstalledInstance(project.id)
if (!newInstance) return
showModpackInstallSuccess(installStore, newInstance, serverAddress)
})
return
}
if (!instance) return
await ensureManagedServerWorldExists(instance.path, project.title, serverAddress)
// Update existing instance if needed
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {
installStore.showUpdateToPlayModal(instance, modpackVersionId, () => {
showUpdateSuccess(installStore, instance, serverAddress)
})
return
}
if (isVanilla && instance.game_version !== recommendedGameVersion) {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
await updateVanillaGameVersion(instance, recommendedGameVersion)
showUpdateSuccess(installStore, instance, serverAddress)
} finally {
installStore.stopInstallingServer(projectId)
}
return
}
// join server
try {
await joinServer(instance.path, serverAddress, project.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
}

View File

@@ -1,6 +1,5 @@
import { useBreadcrumbs } from './breadcrumbs'
import { useInstall } from './install'
import { useLoading } from './loading'
import { useTheming } from './theme.ts'
export { useBreadcrumbs, useInstall, useLoading, useTheming }
export { useBreadcrumbs, useLoading, useTheming }

View File

@@ -44,6 +44,7 @@ fn main() {
"get_search_results_v3",
"get_search_results_v3_many",
"purge_cache_types",
"get_project_versions",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
@@ -164,11 +165,17 @@ fn main() {
"profile_get",
"profile_get_many",
"profile_get_projects",
"profile_get_installed_project_ids",
"profile_get_content_items",
"profile_get_dependencies_as_content_items",
"profile_get_linked_modpack_info",
"profile_get_linked_modpack_content",
"profile_get_optimal_jre_key",
"profile_get_full_path",
"profile_get_mod_full_path",
"profile_list",
"profile_check_installed",
"profile_check_installed_batch",
"profile_install",
"profile_update_all",
"profile_update_project",

View File

@@ -59,6 +59,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_search_results_v3,
get_search_results_v3_many,
purge_cache_types,
get_project_versions,
])
.build()
}
@@ -67,3 +68,14 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
pub async fn purge_cache_types(cache_types: Vec<CacheValueType>) -> Result<()> {
Ok(theseus::cache::purge_cache_types(&cache_types).await?)
}
#[tauri::command]
pub async fn get_project_versions(
project_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Option<Vec<Version>>> {
Ok(
theseus::cache::get_project_versions(project_id, cache_behaviour)
.await?,
)
}

View File

@@ -4,6 +4,7 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use theseus::data::{ContentItem, Dependency, LinkedModpackInfo};
use theseus::prelude::*;
use theseus::profile::QuickPlayType;
use theseus::server_address::ServerAddress;
@@ -15,11 +16,17 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_get,
profile_get_many,
profile_get_projects,
profile_get_installed_project_ids,
profile_get_content_items,
profile_get_dependencies_as_content_items,
profile_get_linked_modpack_info,
profile_get_linked_modpack_content,
profile_get_optimal_jre_key,
profile_get_full_path,
profile_get_mod_full_path,
profile_list,
profile_check_installed,
profile_check_installed_batch,
profile_install,
profile_update_all,
profile_update_project,
@@ -71,6 +78,68 @@ pub async fn profile_get_projects(
Ok(res)
}
#[tauri::command]
pub async fn profile_get_installed_project_ids(
path: &str,
) -> Result<Vec<String>> {
let res = profile::get_installed_project_ids(path).await?;
Ok(res)
}
/// Get content items with rich metadata for a profile
///
/// Returns content items filtered to exclude modpack files (if linked),
/// sorted alphabetically by project name.
#[tauri::command]
pub async fn profile_get_content_items(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Vec<ContentItem>> {
let res = profile::get_content_items(path, cache_behaviour).await?;
Ok(res)
}
/// Convert a list of dependencies into ContentItems with rich metadata
#[tauri::command]
pub async fn profile_get_dependencies_as_content_items(
dependencies: Vec<Dependency>,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Vec<ContentItem>> {
let res = profile::get_dependencies_as_content_items(
dependencies,
cache_behaviour,
)
.await?;
Ok(res)
}
/// Get linked modpack info for a profile
///
/// Returns project, version, and owner information for the linked modpack,
/// or None if the profile is not linked to a modpack.
#[tauri::command]
pub async fn profile_get_linked_modpack_info(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Option<LinkedModpackInfo>> {
let res = profile::get_linked_modpack_info(path, cache_behaviour).await?;
Ok(res)
}
/// Get content items that are part of the linked modpack
///
/// Returns the modpack's dependencies as ContentItem list.
/// Returns empty vec if the profile is not linked to a modpack.
#[tauri::command]
pub async fn profile_get_linked_modpack_content(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Vec<ContentItem>> {
let res =
profile::get_linked_modpack_content(path, cache_behaviour).await?;
Ok(res)
}
// Get a profile's full path
// invoke('plugin:profile|profile_get_full_path',path)
#[tauri::command]
@@ -127,6 +196,28 @@ pub async fn profile_check_installed(
}
}
#[tauri::command]
pub async fn profile_check_installed_batch(
project_id: &str,
) -> Result<HashMap<String, bool>> {
let profiles = profile::list().await?;
let mut result = HashMap::new();
for p in profiles {
let installed =
if let Ok(projects) = profile::get_projects(&p.path, None).await {
projects.into_iter().any(|(_, pf)| {
pf.metadata
.as_ref()
.map_or(false, |m| m.project_id == project_id)
})
} else {
false
};
result.insert(p.path.clone(), installed);
}
Ok(result)
}
/// Installs/Repairs a profile
/// invoke('plugin:profile|profile_install')
#[tauri::command]

View File

@@ -83,13 +83,17 @@ pub async fn should_disable_mouseover() -> bool {
}
#[tauri::command]
pub fn highlight_in_folder<R: Runtime>(
pub async fn highlight_in_folder<R: Runtime>(
app: tauri::AppHandle<R>,
path: PathBuf,
) {
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = app.opener().reveal_item_in_dir(path) {
tracing::error!("Failed to highlight file in folder: {}", e);
}
})
.await
.ok();
}
#[tauri::command]

View File

@@ -0,0 +1,4 @@
BASE_URL=https://api.modrinth.com/v2/
BROWSER_BASE_URL=https://api.modrinth.com/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
PROD_OVERRIDE=true

View File

@@ -68,6 +68,9 @@ export default defineNuxtConfig({
ssr: {
// https://github.com/Akryum/floating-vue/issues/809#issuecomment-1002996240
noExternal: ['v-tooltip'],
optimizeDeps: {
include: ['vue-router'],
},
},
define: {
global: {},

View File

@@ -8,31 +8,11 @@
</NuxtLayout>
</template>
<script setup lang="ts">
import {
I18nDebugPanel,
NotificationPanel,
provideModrinthClient,
provideNotificationManager,
providePageContext,
} from '@modrinth/ui'
import { I18nDebugPanel, NotificationPanel } from '@modrinth/ui'
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
import { createModrinthClient } from '~/helpers/api.ts'
import { FrontendNotificationManager } from '~/providers/frontend-notifications.ts'
import { setupProviders } from '~/providers/setup.ts'
const auth = await useAuth()
const config = useRuntimeConfig()
provideNotificationManager(new FrontendNotificationManager())
const client = createModrinthClient(auth, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
})
setupProviders(auth)
</script>

View File

@@ -4,6 +4,7 @@
:stages="ctx.stageConfigs"
:context="ctx"
:breadcrumbs="!editingVersion"
:close-on-click-outside="false"
@hide="() => (modalOpen = false)"
/>
<DropArea

View File

@@ -42,15 +42,15 @@
<ButtonStyled v-if="content" type="outlined">
<button
class="!border-[1px]"
@click="handleSwitchCompatibility"
:disabled="!hasPermission"
@click="handleSwitchCompatibility"
>
<ArrowLeftRightIcon />
Switch type
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="handleSetCompatibility" :disabled="!hasPermission">
<button :disabled="!hasPermission" @click="handleSetCompatibility">
<ComponentIcon />
Set compatibility
</button>
@@ -189,8 +189,8 @@
<ButtonStyled v-if="content">
<button
class="!w-full !max-w-[160px]"
@click="handleUpdateContent"
:disabled="!hasPermission"
@click="handleUpdateContent"
>
<RefreshCwIcon />
Update

View File

@@ -1,558 +0,0 @@
<template>
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
<template #title>
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
<Avatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
</div>
</template>
<div class="flex flex-col gap-2 md:w-[420px]">
<div class="flex flex-col gap-2">
<template v-if="versionsLoading">
<div class="flex items-center gap-2">
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg font-semibold">
<span class="opacity-0" aria-hidden="true">{{ type }} version</span>
</div>
<div class="min-h-[22px] min-w-[140px] animate-pulse rounded-full bg-button-bg" />
</div>
<div class="min-h-9 w-full animate-pulse rounded-xl bg-button-bg" />
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg">
<span class="ml-6 opacity-0" aria-hidden="true">
Show any beta and alpha releases
</span>
</div>
</template>
<template v-else>
<div class="flex justify-between">
<div class="flex items-center gap-2">
<div class="font-semibold text-contrast">{{ type }} version</div>
<NuxtLink
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
@click="
versionFilter &&
(unlockFilterAccordion.isOpen
? unlockFilterAccordion.close()
: unlockFilterAccordion.open())
"
>
<TagItem
v-if="formattedVersions.game_versions.length > 0"
v-tooltip="formattedVersions.game_versions.join(', ')"
:style="`--_color: var(--color-green)`"
>
{{ formattedVersions.game_versions[0] }}
</TagItem>
<TagItem
v-if="formattedVersions.loaders.length > 0"
v-tooltip="formattedVersions.loaders.join(', ')"
:style="`--_color: var(--color-platform-${formattedVersions.loaders[0].toLowerCase()})`"
>
{{ formattedVersions.loaders[0] }}
</TagItem>
<DropdownIcon
:class="[
'transition-all duration-200 ease-in-out',
{ 'rotate-180': unlockFilterAccordion.isOpen },
{ 'opacity-0': !versionFilter },
]"
/>
</NuxtLink>
</div>
</div>
<Combobox
v-model="selectedVersion"
name="Project"
:options="
filteredVersions.map((v) => ({
value: v,
label: typeof v === 'object' ? v.version_number : String(v),
}))
"
:display-value="
selectedVersion
? typeof selectedVersion === 'object'
? selectedVersion.version_number
: String(selectedVersion)
: 'No valid versions found'
"
class="!min-w-full"
:disabled="filteredVersions.length === 0"
/>
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
</template>
</div>
<Accordion
ref="unlockFilterAccordion"
:open-by-default="!versionFilter"
:class="[
versionFilter ? '' : '!border-solid border-orange bg-bg-orange !text-contrast',
'flex flex-col gap-2 rounded-2xl border-2 border-dashed border-surface-5 p-3 transition-all',
]"
>
<p class="m-0 items-center font-bold">
<span>
{{
noCompatibleVersions
? `No compatible versions of this ${type.toLowerCase()} were found`
: versionFilter
? 'Game version and platform is provided by the server'
: 'Incompatible game version and platform versions are unlocked'
}}
</span>
</p>
<p class="m-0 text-sm">
{{
noCompatibleVersions
? `No versions compatible with your server were found. You can still select any available version.`
: versionFilter
? `Unlocking this filter may allow you to change this ${type.toLowerCase()}
to an incompatible version.`
: "You might see versions listed that aren't compatible with your server configuration."
}}
</p>
<ContentVersionFilter
v-if="currentVersions"
ref="filtersRef"
:versions="currentVersions"
:game-versions="tags.gameVersions"
:select-classes="'w-full'"
:type="type"
:disabled="versionFilter"
:platform-tags="tags.loaders"
:listed-game-versions="gameVersions"
:listed-platforms="platforms"
@update:query="updateFiltersFromUi($event)"
@vue:mounted="updateFiltersToUi"
>
<template #platform>
<LoaderIcon
v-if="filtersRef?.selectedPlatforms.length === 0"
:loader="'Vanilla'"
class="size-5 flex-none"
/>
<component
:is="getLoaderIcon(filtersRef.selectedPlatforms[0])"
v-else-if="
filtersRef?.selectedPlatforms[0] && getLoaderIcon(filtersRef.selectedPlatforms[0])
"
class="size-5 flex-none"
/>
<div class="w-full truncate text-left">
{{
filtersRef?.selectedPlatforms.length === 0
? 'All platforms'
: filtersRef?.selectedPlatforms
.map((x) => {
return formatLoader(formatMessage, x)
})
.join(', ')
}}
</div>
</template>
<template #game-versions>
<GameIcon class="size-5 flex-none" />
<div class="w-full truncate text-left">
{{
filtersRef?.selectedGameVersions.length === 0
? 'All game versions'
: filtersRef?.selectedGameVersions.join(', ')
}}
</div>
</template>
</ContentVersionFilter>
<ButtonStyled v-if="!noCompatibleVersions" color-fill="text">
<button
class="w-full"
:disabled="gameVersions.length < 2 && platforms.length < 2"
@click="
() => {
versionFilter = !versionFilter
setInitialFilters()
updateFiltersToUi()
}
"
>
<LockOpenIcon />
{{
gameVersions.length < 2 && platforms.length < 2
? 'No other platforms or versions available'
: versionFilter
? 'Unlock'
: 'Return to compatibility'
}}
</button>
</ButtonStyled>
</Accordion>
<Admonition
v-if="versionsError"
type="critical"
header="Failed to load versions"
class="mb-2"
>
<div>
<span>
Something went wrong trying to load versions for this
{{ type.toLocaleLowerCase() }}. Please try again later or contact support if the issue
persists.
</span>
<CopyCode class="!mt-2 !break-all" :text="versionsError" />
</div>
</Admonition>
<Admonition
v-else-if="props.modPack"
type="warning"
header="Changing version may cause issues"
class="mb-2"
>
Your server was created using a modpack. It's recommended to use the modpack's version of
the mod.
<NuxtLink
class="mt-2 flex items-center gap-1"
:to="`/hosting/manage/${props.serverId}/options/loader`"
target="_blank"
>
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version
</NuxtLink>
</Admonition>
<div class="flex flex-row items-center gap-4">
<ButtonStyled color="brand">
<button
:disabled="versionsLoading || selectedVersion.id === modDetails?.version_id"
@click="emitChangeModVersion"
>
<CheckIcon />
Install
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modModal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import {
CheckIcon,
DropdownIcon,
ExternalIcon,
GameIcon,
getLoaderIcon,
LockOpenIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
Checkbox,
Combobox,
CopyCode,
formatLoader,
NewModal,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { formatVersionsForDisplay, type Mod, type Version } from '@modrinth/utils'
import { computed, ref } from 'vue'
import Accordion from '~/components/ui/Accordion.vue'
import ContentVersionFilter, {
type ListedGameVersion,
type ListedPlatform,
} from '~/components/ui/servers/ContentVersionFilter.vue'
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
type: 'Mod' | 'Plugin'
loader: string
gameVersion: string
modPack: boolean
serverId: string
}>()
interface ContentItem extends Mod {
changing?: boolean
}
interface EditVersion extends Version {
installed: boolean
upgrade?: boolean
}
const modModal = ref()
const modDetails = ref<ContentItem>()
const currentVersions = ref<EditVersion[] | null>(null)
const versionsLoading = ref(false)
const versionsError = ref('')
const showBetaAlphaReleases = ref(false)
const unlockFilterAccordion = ref()
const versionFilter = ref(true)
const tags = useGeneratedState()
const noCompatibleVersions = ref(false)
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
(acc, tag) => {
if (tag.supported_project_types.includes('plugin')) {
acc.pluginLoaders.push(tag.name)
}
if (tag.supported_project_types.includes('mod')) {
acc.modLoaders.push(tag.name)
}
return acc
},
{ pluginLoaders: [] as string[], modLoaders: [] as string[] },
)
const selectedVersion = ref()
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null)
interface SelectedContentFilters {
selectedGameVersions: string[]
selectedPlatforms: string[]
}
const selectedFilters = ref<SelectedContentFilters>({
selectedGameVersions: [],
selectedPlatforms: [],
})
const backwardCompatPlatformMap = {
purpur: ['purpur', 'paper', 'spigot', 'bukkit'],
paper: ['paper', 'spigot', 'bukkit'],
spigot: ['spigot', 'bukkit'],
}
const platforms = ref<ListedPlatform[]>([])
const gameVersions = ref<ListedGameVersion[]>([])
const initPlatform = ref<string>('')
const setInitialFilters = () => {
selectedFilters.value = {
selectedGameVersions: [
gameVersions.value.find((version) => version.name === props.gameVersion)?.name ??
gameVersions.value.find((version) => version.release)?.name ??
gameVersions.value[0]?.name,
],
selectedPlatforms: [initPlatform.value],
}
}
const updateFiltersToUi = () => {
if (!filtersRef.value) return
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms
selectedVersion.value = filteredVersions.value[0]
}
const updateFiltersFromUi = (event: { g: string[]; l: string[] }) => {
selectedFilters.value = {
selectedGameVersions: event.g,
selectedPlatforms: event.l,
}
}
const filteredVersions = computed(() => {
if (!currentVersions.value) return []
const versionsWithoutReleaseFilter = currentVersions.value.filter((version: EditVersion) => {
if (version.installed) return true
return (
filtersRef.value?.selectedPlatforms.every((platform) =>
(
backwardCompatPlatformMap[platform as keyof typeof backwardCompatPlatformMap] || [
platform,
]
).some((loader) => version.loaders.includes(loader)),
) &&
filtersRef.value?.selectedGameVersions.every((gameVersion) =>
version.game_versions.includes(gameVersion),
)
)
})
const versionTypes = new Set(versionsWithoutReleaseFilter.map((v: EditVersion) => v.version_type))
const releaseVersions = versionTypes.has('release')
const betaVersions = versionTypes.has('beta')
const alphaVersions = versionTypes.has('alpha')
const versions = versionsWithoutReleaseFilter.filter((version: EditVersion) => {
if (showBetaAlphaReleases.value || version.installed) return true
return releaseVersions
? version.version_type === 'release'
: betaVersions
? version.version_type === 'beta'
: alphaVersions
? version.version_type === 'alpha'
: false
})
return versions.map((version: EditVersion) => {
let suffix = ''
if (version.version_type === 'alpha' && releaseVersions && betaVersions) {
suffix += ' (alpha)'
} else if (version.version_type === 'beta' && releaseVersions) {
suffix += ' (beta)'
}
return {
...version,
version_number: version.version_number + suffix,
}
})
})
const formattedVersions = computed(() => {
return {
game_versions: formatVersionsForDisplay(
selectedVersion.value?.game_versions || [],
tags.value.gameVersions,
),
loaders: (selectedVersion.value?.loaders || [])
.sort((firstLoader: string, secondLoader: string) => {
const loaderList = backwardCompatPlatformMap[
props.loader as keyof typeof backwardCompatPlatformMap
] || [props.loader]
const firstLoaderPosition = loaderList.indexOf(firstLoader.toLowerCase())
const secondLoaderPosition = loaderList.indexOf(secondLoader.toLowerCase())
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0
if (firstLoaderPosition === -1) return 1
if (secondLoaderPosition === -1) return -1
return firstLoaderPosition - secondLoaderPosition
})
.map((loader: string) => formatLoader(formatMessage, loader)),
}
})
async function show(mod: ContentItem) {
versionFilter.value = true
modModal.value.show()
versionsLoading.value = true
modDetails.value = mod
versionsError.value = ''
currentVersions.value = null
try {
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false)
if (
Array.isArray(result) &&
result.every(
(item) =>
'id' in item &&
'version_number' in item &&
'version_type' in item &&
'loaders' in item &&
'game_versions' in item,
)
) {
currentVersions.value = result as EditVersion[]
} else {
throw new Error('Invalid version data received.')
}
// find the installed version and move it to the top of the list
const currentModIndex = currentVersions.value.findIndex(
(item: { id: string }) => item.id === mod.version_id,
)
if (currentModIndex === -1) {
currentVersions.value[currentModIndex] = {
...currentVersions.value[currentModIndex],
installed: true,
version_number: `${mod.version_number} (current) (external)`,
}
} else {
currentVersions.value[currentModIndex].version_number = `${mod.version_number} (current)`
currentVersions.value[currentModIndex].installed = true
}
// initially filter the platform and game versions for the server config
const platformSet = new Set<string>()
const gameVersionSet = new Set<string>()
for (const version of currentVersions.value) {
for (const loader of version.loaders) {
platformSet.add(loader)
}
for (const gameVersion of version.game_versions) {
gameVersionSet.add(gameVersion)
}
}
if (gameVersionSet.size > 0) {
const filteredGameVersions = tags.value.gameVersions.filter((x) =>
gameVersionSet.has(x.version),
)
gameVersions.value = filteredGameVersions.map((x) => ({
name: x.version,
release: x.version_type === 'release',
}))
}
if (platformSet.size > 0) {
const tempPlatforms = Array.from(platformSet).map((platform) => ({
name: platform,
isType:
props.type === 'Plugin'
? pluginLoaders.includes(platform)
: props.type === 'Mod'
? modLoaders.includes(platform)
: false,
}))
platforms.value = tempPlatforms
}
// set default platform
const defaultPlatform = Array.from(platformSet)[0]
initPlatform.value = platformSet.has(props.loader)
? props.loader
: props.loader in backwardCompatPlatformMap
? backwardCompatPlatformMap[props.loader as keyof typeof backwardCompatPlatformMap].find(
(p) => platformSet.has(p),
) || defaultPlatform
: defaultPlatform
// check if there's nothing compatible with the server config
noCompatibleVersions.value =
!platforms.value.some((p) => p.isType) ||
!gameVersions.value.some((v) => v.name === props.gameVersion)
if (noCompatibleVersions.value) {
unlockFilterAccordion.value.open()
versionFilter.value = false
}
setInitialFilters()
versionsLoading.value = false
} catch (error) {
console.error('Error loading versions:', error)
versionsError.value = error instanceof Error ? error.message : 'Unknown'
}
}
const emit = defineEmits<{
changeVersion: [string]
}>()
function emitChangeModVersion() {
if (!selectedVersion.value) return
emit('changeVersion', selectedVersion.value.id.toString())
}
defineExpose({
show,
hide: () => modModal.value.hide(),
})
</script>

View File

@@ -1,345 +0,0 @@
<template>
<li
role="button"
:class="[
containerClasses,
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
isDragging ? 'opacity-50' : '',
]"
tabindex="0"
draggable="true"
@click="selectItem"
@contextmenu="openContextMenu"
@keydown="(e) => e.key === 'Enter' && selectItem()"
@mouseenter="handleMouseEnter"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div class="pointer-events-none flex flex-1 items-center gap-3 truncate">
<Checkbox
class="pointer-events-auto"
:model-value="selected"
@click.stop
@update:model-value="emit('toggle-select')"
/>
<div class="pointer-events-none flex size-5 items-center justify-center">
<component :is="iconComponent" class="size-5" />
</div>
<div class="pointer-events-none flex flex-col truncate">
<span
class="pointer-events-none truncate group-hover:text-contrast group-focus:text-contrast"
>
{{ name }}
</span>
</div>
</div>
<div class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
<span class="hidden w-[100px] text-nowrap text-sm text-secondary md:block">
{{ formattedSize }}
</span>
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
{{ formattedCreationDate }}
</span>
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
{{ formattedModifiedDate }}
</span>
<ButtonStyled circular type="transparent">
<TeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #extract><PackageOpenIcon /> Extract</template>
<template #rename><EditIcon /> Rename</template>
<template #move><RightArrowIcon /> Move</template>
<template #download><DownloadIcon /> Download</template>
<template #delete><TrashIcon /> Delete</template>
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</li>
</template>
<script setup lang="ts">
import {
DownloadIcon,
EditIcon,
FolderOpenIcon,
MoreHorizontalIcon,
PackageOpenIcon,
PaletteIcon,
RightArrowIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
getFileExtension,
getFileExtensionIcon,
isEditableFile as isEditableFileExt,
isImageFile,
useFormatDateTime,
} from '@modrinth/ui'
import { computed, h, ref, shallowRef } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { useRoute, useRouter } from 'vue-router'
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
interface FileItemProps {
name: string
type: 'directory' | 'file'
size?: number
count?: number
modified: number
created: number
path: string
index: number
isLast: boolean
selected: boolean
}
const props = defineProps<FileItemProps>()
const emit = defineEmits<{
(
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover',
item: { name: string; type: string; path: string },
): void
(e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void
(e: 'contextmenu', x: number, y: number): void
(e: 'toggle-select'): void
}>()
const isDragOver = ref(false)
const isDragging = ref(false)
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
const route = shallowRef(useRoute())
const router = useRouter()
const formatDateTime = useFormatDateTime({
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
})
const containerClasses = computed(() => [
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-3 px-4 py-3 focus:!outline-none',
props.index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-3',
props.isLast ? 'rounded-b-[20px] border-b' : '',
isEditableFile.value ? 'cursor-pointer' : props.type === 'directory' ? 'cursor-pointer' : '',
isDragOver.value ? '!bg-brand-highlight' : '',
'hover:brightness-110 focus:brightness-110',
])
const fileExtension = computed(() => getFileExtension(props.name))
const isZip = computed(() => fileExtension.value === 'zip')
const menuOptions = computed(() => [
{
id: 'extract',
shown: isZip.value,
action: () => emit('extract', { name: props.name, type: props.type, path: props.path }),
},
{
divider: true,
shown: isZip.value,
},
{
id: 'rename',
action: () => emit('rename', { name: props.name, type: props.type, path: props.path }),
},
{
id: 'move',
action: () => emit('move', { name: props.name, type: props.type, path: props.path }),
},
{
id: 'download',
action: () => emit('download', { name: props.name, type: props.type, path: props.path }),
shown: props.type !== 'directory',
},
{
id: 'delete',
action: () => emit('delete', { name: props.name, type: props.type, path: props.path }),
color: 'red' as const,
},
])
const iconComponent = computed(() => {
if (props.type === 'directory') {
if (props.name === 'config') return UiServersIconsCogFolderIcon
if (props.name === 'world') return UiServersIconsEarthIcon
if (props.name === 'resourcepacks') return PaletteIcon
return FolderOpenIcon
}
return getFileExtensionIcon(fileExtension.value)
})
const formattedModifiedDate = computed(() => {
const date = new Date(props.modified * 1000)
return formatDateTime(date)
})
const formattedCreationDate = computed(() => {
const date = new Date(props.created * 1000)
return formatDateTime(date)
})
const isEditableFile = computed(() => {
if (props.type === 'file') {
const ext = fileExtension.value
return !props.name.includes('.') || isEditableFileExt(ext) || isImageFile(ext)
}
return false
})
const formattedSize = computed(() => {
if (props.type === 'directory') {
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
}
if (props.size === undefined) return ''
const bytes = props.size
if (bytes === 0) return '0 B'
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
return `${size} ${units[exponent]}`
})
function openContextMenu(event: MouseEvent) {
event.preventDefault()
emit('contextmenu', event.clientX, event.clientY)
}
function handleMouseEnter() {
emit('hover', { name: props.name, type: props.type, path: props.path })
}
function navigateToFolder() {
const currentPath = route.value.query.path?.toString() || ''
const newPath = currentPath.endsWith('/')
? `${currentPath}${props.name}`
: `${currentPath}/${props.name}`
router.push({ query: { path: newPath } })
}
const isNavigating = ref(false)
function selectItem() {
if (isNavigating.value) return
isNavigating.value = true
if (props.type === 'directory') {
navigateToFolder()
} else if (props.type === 'file' && isEditableFile.value) {
emit('edit', { name: props.name, type: props.type, path: props.path })
}
setTimeout(() => {
isNavigating.value = false
}, 500)
}
async function getDragIcon() {
// Reuse iconComponent computed for consistency
return await renderToString(h(iconComponent.value))
}
async function handleDragStart(event: DragEvent) {
if (!event.dataTransfer) return
isDragging.value = true
const dragGhost = document.createElement('div')
dragGhost.className =
'fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none'
const iconContainer = document.createElement('div')
iconContainer.className = 'flex size-6 items-center justify-center'
const icon = document.createElement('div')
icon.className = 'size-4'
icon.innerHTML = await getDragIcon()
iconContainer.appendChild(icon)
const nameSpan = document.createElement('span')
nameSpan.className = 'font-bold truncate text-contrast'
nameSpan.textContent = props.name
dragGhost.appendChild(iconContainer)
dragGhost.appendChild(nameSpan)
document.body.appendChild(dragGhost)
event.dataTransfer.setDragImage(dragGhost, 0, 0)
requestAnimationFrame(() => {
document.body.removeChild(dragGhost)
})
event.dataTransfer.setData(
'application/modrinth-file-move',
JSON.stringify({
name: props.name,
type: props.type,
path: props.path,
}),
)
event.dataTransfer.effectAllowed = 'move'
}
function isChildPath(parentPath: string, childPath: string) {
return childPath.startsWith(parentPath + '/')
}
function handleDragEnd() {
isDragging.value = false
}
function handleDragEnter() {
if (props.type !== 'directory') return
isDragOver.value = true
}
function handleDragOver(event: DragEvent) {
if (props.type !== 'directory' || !event.dataTransfer) return
event.dataTransfer.dropEffect = 'move'
}
function handleDragLeave() {
isDragOver.value = false
}
function handleDrop(event: DragEvent) {
isDragOver.value = false
if (props.type !== 'directory' || !event.dataTransfer) return
try {
const dragData = JSON.parse(event.dataTransfer.getData('application/modrinth-file-move'))
if (dragData.path === props.path) return
if (dragData.type === 'directory' && isChildPath(dragData.path, props.path)) {
console.error('Cannot move a folder into its own subfolder')
return
}
emit('moveDirectTo', {
name: dragData.name,
type: dragData.type,
path: dragData.path,
destination: props.path,
})
} catch (error) {
console.error('Error handling file drop:', error)
}
}
</script>

View File

@@ -1,41 +0,0 @@
<template>
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
<FileIcon class="size-28" />
<div class="flex flex-col gap-2">
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
<p class="m-0 text-sm text-secondary">
{{ message }}
</p>
<div class="flex gap-2">
<ButtonStyled>
<button size="sm" @click="$emit('refetch')">
<LoadingIcon class="h-5 w-5" />
Try again
</button>
</ButtonStyled>
<ButtonStyled>
<button size="sm" @click="$emit('home')">
<HomeIcon class="h-5 w-5" />
Go to home folder
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FileIcon, HomeIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import LoadingIcon from './icons/LoadingIcon.vue'
defineProps<{
title: string
message: string
}>()
defineEmits<{
(e: 'refetch' | 'home'): void
}>()
</script>

View File

@@ -1,128 +0,0 @@
<template>
<div ref="listContainer" class="relative w-full">
<div
:style="{
position: 'relative',
minHeight: `${totalHeight}px`,
}"
>
<ul
class="list-none"
:style="{
position: 'absolute',
top: `${visibleTop}px`,
width: '100%',
margin: 0,
padding: 0,
}"
>
<FileItem
v-for="(item, idx) in visibleItems"
:key="item.path"
:count="item.count"
:created="item.created"
:modified="item.modified"
:name="item.name"
:path="item.path"
:type="item.type"
:size="item.size"
:index="visibleRange.start + idx"
:is-last="visibleRange.start + idx === props.items.length - 1"
:selected="selectedItems.has(item.path)"
@delete="$emit('delete', item)"
@rename="$emit('rename', item)"
@extract="$emit('extract', item)"
@download="$emit('download', item)"
@move="$emit('move', item)"
@move-direct-to="$emit('moveDirectTo', $event)"
@edit="$emit('edit', item)"
@hover="$emit('hover', item)"
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
@toggle-select="$emit('toggle-select', item.path)"
/>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import FileItem from './FileItem.vue'
const props = defineProps<{
items: any[]
selectedItems: Set<string>
}>()
const emit = defineEmits<{
(
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract' | 'hover',
item: any,
): void
(e: 'contextmenu', item: any, x: number, y: number): void
(e: 'loadMore'): void
(e: 'toggle-select', path: string): void
}>()
const ITEM_HEIGHT = 61
const BUFFER_SIZE = 5
const listContainer = ref<HTMLElement | null>(null)
const windowScrollY = ref(0)
const windowHeight = ref(0)
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT)
const visibleRange = computed(() => {
if (!listContainer.value) return { start: 0, end: 0 }
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop)
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT)
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
}
})
const visibleTop = computed(() => {
return visibleRange.value.start * ITEM_HEIGHT
})
const visibleItems = computed(() => {
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
})
function handleScroll() {
windowScrollY.value = window.scrollY
if (!listContainer.value) return
const containerBottom = listContainer.value.getBoundingClientRect().bottom
const remainingScroll = containerBottom - window.innerHeight
if (remainingScroll < windowHeight.value * 0.2) {
emit('loadMore')
}
}
function handleResize() {
windowHeight.value = window.innerHeight
}
onMounted(() => {
windowHeight.value = window.innerHeight
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
handleScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
</script>

View File

@@ -1,173 +0,0 @@
<template>
<header
class="flex select-none flex-col justify-between gap-2 sm:flex-row sm:items-center"
aria-label="File navigation"
>
<nav
aria-label="Breadcrumb navigation"
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
>
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
<li class="mr-4 flex-shrink-0">
<ButtonStyled circular>
<button
v-tooltip="'Back to home'"
type="button"
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="$emit('navigate', -1)"
@mouseenter="$emit('prefetch-home')"
>
<HomeIcon />
<span class="sr-only">Home</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
<TransitionGroup
name="breadcrumb"
tag="span"
class="relative flex min-w-0 flex-shrink items-center"
>
<li
v-for="(segment, index) in breadcrumbSegments"
:key="`${segment || index}-group`"
class="relative flex min-w-0 flex-shrink items-center text-sm"
>
<div class="flex min-w-0 flex-shrink items-center">
<ButtonStyled type="transparent">
<button
class="cursor-pointer truncate focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:aria-current="
index === breadcrumbSegments.length - 1 ? 'location' : undefined
"
:class="{
'!text-contrast': index === breadcrumbSegments.length - 1,
}"
@click="$emit('navigate', index)"
>
{{ segment || '' }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbSegments.length - 1"
class="size-4 flex-shrink-0 text-secondary"
aria-hidden="true"
/>
</div>
</li>
</TransitionGroup>
</ol>
</li>
</ol>
</nav>
<div class="flex flex-shrink-0 items-center gap-2">
<StyledInput
id="search-folder"
:model-value="searchQuery"
:icon="SearchIcon"
type="search"
name="search"
autocomplete="off"
placeholder="Search files"
wrapper-class="w-full sm:w-[280px]"
@update:model-value="$emit('update:searchQuery', $event)"
/>
<ButtonStyled type="outlined">
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
position="bottom"
direction="left"
aria-label="Create new..."
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
:options="[
{ id: 'file', action: () => $emit('create', 'file') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
{ divider: true },
{ id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
{ id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
]"
>
<PlusIcon aria-hidden="true" class="h-5 w-5" />
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
<template #upload-zip>
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
</template>
<template #install-from-url>
<LinkIcon aria-hidden="true" /> Upload from .zip URL
</template>
<template #install-cf-pack>
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</header>
</template>
<script setup lang="ts">
import {
BoxIcon,
ChevronRightIcon,
CurseForgeIcon,
DropdownIcon,
FileArchiveIcon,
FolderOpenIcon,
HomeIcon,
LinkIcon,
PlusIcon,
SearchIcon,
UploadIcon,
} from '@modrinth/assets'
import { ButtonStyled, OverflowMenu, StyledInput } from '@modrinth/ui'
defineProps<{
breadcrumbSegments: string[]
searchQuery: string
currentFilter: string
baseId: string
}>()
defineEmits<{
(e: 'navigate', index: number): void
(e: 'create', type: 'file' | 'directory'): void
(e: 'upload' | 'upload-zip' | 'prefetch-home'): void
(e: 'unzip-from-url', cf: boolean): void
(e: 'update:searchQuery' | 'filter', value: string): void
}>()
</script>
<style scoped>
.breadcrumb-move,
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.2s ease;
}
.breadcrumb-enter-from {
opacity: 0;
transform: translateX(-10px) scale(0.9);
}
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.8);
filter: blur(4px);
}
.breadcrumb-leave-active {
position: relative;
pointer-events: none;
}
.breadcrumb-move {
z-index: 1;
}
</style>

View File

@@ -1,104 +0,0 @@
<template>
<div
class="fixed"
:style="{
transform: `translateY(${isAtBottom ? '-100%' : '0'})`,
top: `${y}px`,
left: `${x}px`,
}"
>
<Transition>
<div
v-if="item"
id="item-context-menu"
ref="ctxRef"
:style="{
border: '1px solid var(--color-divider)',
borderRadius: 'var(--radius-lg)',
backgroundColor: 'var(--color-raised-bg)',
padding: 'var(--gap-sm)',
boxShadow: 'var(--shadow-floating)',
gap: 'var(--gap-xs)',
width: 'max-content',
}"
class="flex h-fit w-fit select-none flex-col"
>
<button
class="btn btn-transparent flex !w-full items-center"
@click="$emit('rename', item)"
>
<EditIcon class="h-5 w-5" />
Rename
</button>
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
<RightArrowIcon />
Move
</button>
<button
v-if="item.type !== 'directory'"
class="btn btn-transparent flex !w-full items-center"
@click="$emit('download', item)"
>
<DownloadIcon class="h-5 w-5" />
Download
</button>
<button
class="btn btn-transparent btn-red flex !w-full items-center"
@click="$emit('delete', item)"
>
<TrashIcon class="h-5 w-5" />
Delete
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon, EditIcon, RightArrowIcon, TrashIcon } from '@modrinth/assets'
import { ref } from 'vue'
interface FileItem {
type: string
name: string
[key: string]: any
}
defineProps<{
item: FileItem | null
x: number
y: number
isAtBottom: boolean
}>()
const ctxRef = ref<HTMLElement | null>(null)
defineEmits<{
(e: 'rename' | 'move' | 'download' | 'delete', item: FileItem): void
}>()
defineExpose({
ctxRef,
})
</script>
<style scoped>
#item-context-menu {
transition:
transform 0.1s ease,
opacity 0.1s ease;
transform-origin: top left;
}
#item-context-menu.v-enter-active,
#item-context-menu.v-leave-active {
transform: scale(1);
opacity: 1;
}
#item-context-menu.v-enter-from,
#item-context-menu.v-leave-to {
transform: scale(0.5);
opacity: 0;
}
</style>

View File

@@ -1,94 +0,0 @@
<template>
<NewModal ref="modal" :header="`Creating a ${displayType}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Name</div>
<StyledInput
ref="createInput"
v-model="itemName"
autofocus
wrapper-class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!!error" type="submit">
<PlusIcon class="h-5 w-5" />
Create {{ displayType }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const props = defineProps<{
type: 'file' | 'directory'
}>()
const emit = defineEmits<{
(e: 'create', name: string): void
}>()
const modal = ref<typeof NewModal>()
const displayType = computed(() => (props.type === 'directory' ? 'folder' : props.type))
const createInput = ref<HTMLInputElement | null>(null)
const itemName = ref('')
const submitted = ref(false)
const error = computed(() => {
if (!itemName.value) {
return 'Name is required.'
}
if (props.type === 'file') {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
}
}
return ''
})
const handleSubmit = () => {
submitted.value = true
if (!error.value) {
emit('create', itemName.value)
hide()
}
}
const show = () => {
itemName.value = ''
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
createInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,77 +0,0 @@
<template>
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-brand-red bg-bg-red p-6 shadow-md"
>
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-highlight-red p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
</div>
<div class="flex flex-col">
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
<span
v-if="item?.type === 'directory'"
class="text-xs text-secondary group-hover:text-primary"
>
{{ item?.count }} items
</span>
<span v-else class="text-xs text-secondary group-hover:text-primary">
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
</span>
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="red">
<button type="submit">
<TrashIcon class="h-5 w-5" />
Delete {{ item?.type }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
defineProps<{
item: {
name: string
type: string
count?: number
size?: number
} | null
}>()
const emit = defineEmits<{
(e: 'delete'): void
}>()
const modal = ref<typeof NewModal>()
const handleSubmit = () => {
emit('delete')
hide()
}
const show = () => {
modal.value?.show()
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,136 +0,0 @@
<template>
<header
data-pyro-files-state="editing"
class="flex select-none items-center justify-between gap-2 sm:flex-row"
aria-label="File editor navigation"
>
<nav
aria-label="Breadcrumb navigation"
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
>
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
<li class="mr-4 flex-shrink-0">
<ButtonStyled circular>
<button
v-tooltip="'Back to home'"
type="button"
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="goHome"
>
<HomeIcon />
<span class="sr-only">Home</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 p-0">
<ol class="m-0 flex items-center p-0">
<li
v-for="(segment, index) in breadcrumbSegments"
:key="index"
class="flex items-center text-sm"
>
<ButtonStyled type="transparent">
<button
class="cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:class="{
'!text-contrast': index === breadcrumbSegments.length - 1,
}"
@click="$emit('navigate', index)"
>
{{ segment || '' }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbSegments.length"
class="size-4 text-secondary"
aria-hidden="true"
/>
</li>
<li class="flex items-center px-3 text-sm">
<span class="font-semibold !text-contrast" aria-current="location">{{
fileName
}}</span>
</li>
</ol>
</li>
</ol>
</nav>
<div v-if="!isImage" class="flex gap-2">
<Button
v-if="isLogFile"
v-tooltip="'Share to mclo.gs'"
icon-only
transparent
aria-label="Share to mclo.gs"
@click="$emit('share')"
>
<ShareIcon />
</Button>
<ButtonStyled type="transparent">
<TeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Save file"
:options="[
{ id: 'save', action: () => $emit('save') },
{ id: 'save-as', action: () => $emit('save-as') },
{ id: 'save&restart', action: () => $emit('save-restart') },
]"
>
<SaveIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
<template #save&restart>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
clip-rule="evenodd"
/>
</svg>
Save & restart
</template>
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</header>
</template>
<script setup lang="ts">
import { ChevronRightIcon, DropdownIcon, HomeIcon, SaveIcon, ShareIcon } from '@modrinth/assets'
import { Button, ButtonStyled } from '@modrinth/ui'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
const props = defineProps<{
breadcrumbSegments: string[]
fileName?: string
isImage: boolean
filePath?: string
}>()
const isLogFile = computed(() => {
return props.filePath?.startsWith('logs') || props.filePath?.endsWith('.log')
})
const route = useRoute()
const router = useRouter()
const emit = defineEmits<{
(e: 'cancel' | 'save' | 'save-as' | 'save-restart' | 'share'): void
(e: 'navigate', index: number): void
}>()
const goHome = () => {
emit('cancel')
router.push({ path: '/hosting/manage/' + route.params.id + '/files' })
}
</script>

View File

@@ -1,260 +0,0 @@
<template>
<div class="flex h-full w-full flex-col gap-4">
<FilesRenameItemModal ref="renameModal" :item="file" @rename="handleRenameItem" />
<FilesEditingNavbar
:file-name="file?.name"
:is-image="isEditingImage"
:file-path="file?.path"
class="-mt-2"
:breadcrumb-segments="breadcrumbSegments"
@cancel="handleCancel"
@save="() => saveFileContent(true)"
@save-as="saveFileContentAs"
@save-restart="saveFileContentRestart"
@share="requestShareLink"
@navigate="(index) => emit('navigate', index)"
/>
<div class="flex flex-col shadow-md">
<div class="h-full w-full flex-grow">
<component
:is="props.editorComponent"
v-if="!isEditingImage && props.editorComponent"
v-model:value="fileContent"
:lang="editorLanguage"
theme="modrinth"
:print-margin="false"
style="height: 750px; font-size: 1rem"
class="ace-modrinth rounded-[20px]"
@init="onEditorInit"
/>
<FilesImageViewer v-else-if="isEditingImage && imagePreview" :image-blob="imagePreview" />
<div
v-else-if="isLoading || !props.editorComponent"
class="flex h-[750px] items-center justify-center rounded-[20px] bg-bg-raised"
>
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import {
getEditorLanguage,
getFileExtension,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
isImageFile,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import FilesEditingNavbar from '~/components/ui/servers/FilesEditingNavbar.vue'
import FilesImageViewer from '~/components/ui/servers/FilesImageViewer.vue'
import FilesRenameItemModal from '~/components/ui/servers/FilesRenameItemModal.vue'
const props = defineProps<{
file: { name: string; type: string; path: string } | null
breadcrumbSegments: string[]
editorComponent: any
}>()
const emit = defineEmits<{
close: []
navigate: [index: number]
}>()
const notifications = injectNotificationManager()
const { addNotification } = notifications
const client = injectModrinthClient()
const serverContext = injectModrinthServerContext()
const { serverId } = serverContext
const queryClient = useQueryClient()
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
// Internal state
const fileContent = ref('')
const isEditingImage = ref(false)
const imagePreview = ref<Blob | null>(null)
const isLoading = ref(false)
const renameModal = ref()
const closeAfterRename = ref(false)
const editorInstance = ref<any>(null)
const editorLanguage = computed(() => {
const ext = getFileExtension(props.file?.name ?? '')
return getEditorLanguage(ext)
})
// Load file content when file prop changes
watch(
() => props.file,
async (newFile) => {
if (newFile) {
await loadFileContent(newFile)
} else {
resetState()
}
},
{ immediate: true },
)
async function loadFileContent(file: { name: string; type: string; path: string }) {
isLoading.value = true
try {
window.scrollTo(0, 0)
const extension = getFileExtension(file.name)
if (file.type === 'file' && isImageFile(extension)) {
// Images are not prefetched, fetch directly
const content = await client.kyros.files_v0.downloadFile(file.path)
isEditingImage.value = true
imagePreview.value = content
} else {
isEditingImage.value = false
// Check cache first for text files (may have been prefetched on hover)
const cachedContent = queryClient.getQueryData<string>(['file-content', serverId, file.path])
if (cachedContent) {
fileContent.value = cachedContent
} else {
const content = await client.kyros.files_v0.downloadFile(file.path)
fileContent.value = await content.text()
}
}
} catch (error) {
console.error('Error fetching file content:', error)
addNotification({
title: 'Failed to open file',
text: 'Could not load file contents.',
type: 'error',
})
emit('close')
} finally {
isLoading.value = false
}
}
function resetState() {
fileContent.value = ''
isEditingImage.value = false
imagePreview.value = null
closeAfterRename.value = false
}
function onEditorInit(editor: any) {
editorInstance.value = editor
editor.commands.addCommand({
name: 'save',
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
exec: () => saveFileContent(false),
})
}
async function saveFileContent(exit: boolean = true) {
if (!props.file) return
try {
await client.kyros.files_v0.updateFile(props.file.path, fileContent.value)
if (exit) {
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
emit('close')
}
addNotification({
title: 'File saved',
text: 'Your file has been saved.',
type: 'success',
})
} catch (error) {
console.error('Error saving file content:', error)
addNotification({ title: 'Save failed', text: 'Could not save the file.', type: 'error' })
}
}
async function saveFileContentRestart() {
await saveFileContent(false)
await client.archon.servers_v0.power(serverId, 'Restart')
addNotification({
title: 'Server restarted',
text: 'Your server has been restarted.',
type: 'success',
})
emit('close')
}
async function saveFileContentAs() {
await saveFileContent(false)
closeAfterRename.value = true
renameModal.value?.show(props.file)
}
async function handleRenameItem(newName: string) {
if (!props.file) return
try {
await client.kyros.files_v0.renameFileOrFolder(props.file.path, newName)
addNotification({ title: 'Renamed', text: `Renamed to ${newName}`, type: 'success' })
if (closeAfterRename.value) {
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
closeAfterRename.value = false
emit('close')
}
} catch (err: any) {
addNotification({ title: 'Rename failed', text: err.message, type: 'error' })
}
}
async function requestShareLink() {
try {
const response = (await $fetch('https://api.mclo.gs/1/log', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ content: fileContent.value }),
})) as any
if (response.success) {
await navigator.clipboard.writeText(response.url)
addNotification({
title: 'Log URL copied',
text: 'Your log file URL has been copied to your clipboard.',
type: 'success',
})
} else {
throw new Error(response.error)
}
} catch (error) {
console.error('Error sharing file:', error)
addNotification({
title: 'Failed to share file',
text: 'Could not upload to mclo.gs.',
type: 'error',
})
}
}
function handleCancel() {
resetState()
emit('close')
}
onMounted(async () => {
await modulesLoaded
})
onUnmounted(() => {
editorInstance.value = null
resetState()
})
</script>

View File

@@ -1,180 +0,0 @@
<template>
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
<div
ref="container"
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-[20px] bg-black active:cursor-grabbing"
@mousedown="startPan"
@mousemove="handlePan"
@mouseup="stopPan"
@mouseleave="stopPan"
@wheel.prevent="handleWheel"
>
<div v-if="state.isLoading" />
<div
v-if="state.hasError"
class="flex h-full w-full flex-col items-center justify-center gap-8"
>
<PanelErrorIcon />
<p class="m-0">{{ state.errorMessage || 'Invalid or empty image file.' }}</p>
</div>
<img
v-show="isReady"
ref="imageRef"
:src="imageObjectUrl"
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
:style="imageStyle"
alt="Viewed image"
@load="handleImageLoad"
@error="handleImageError"
/>
</div>
<div
v-if="!state.hasError"
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
>
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
<button v-tooltip="'Zoom in'">
<ZoomInIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
<button v-tooltip="'Zoom out'">
<ZoomOutIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" @click="reset">
<button>
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
<span class="ml-4 text-sm text-blue">Reset</span>
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import PanelErrorIcon from './icons/PanelErrorIcon.vue'
const ZOOM_MIN = 0.1
const ZOOM_MAX = 5
const ZOOM_IN_FACTOR = 1.2
const ZOOM_OUT_FACTOR = 0.8
const INITIAL_SCALE = 0.5
const MAX_IMAGE_DIMENSION = 4096
const props = defineProps<{
imageBlob: Blob
}>()
const state = ref({
scale: INITIAL_SCALE,
translateX: 0,
translateY: 0,
isPanning: false,
startX: 0,
startY: 0,
isLoading: false,
hasError: false,
errorMessage: '',
})
const imageRef = ref<HTMLImageElement | null>(null)
const container = ref<HTMLElement | null>(null)
const imageObjectUrl = ref('')
const rafId = ref(0)
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
const imageStyle = computed(() => ({
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
transition: state.value.isPanning ? 'none' : 'transform 0.3s ease-out',
}))
const validateImageDimensions = (img: HTMLImageElement): boolean => {
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
state.value.hasError = true
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`
return false
}
return true
}
const updateImageUrl = (blob: Blob) => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
imageObjectUrl.value = URL.createObjectURL(blob)
}
const handleImageLoad = () => {
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
state.value.isLoading = false
return
}
state.value.isLoading = false
reset()
}
const handleImageError = () => {
state.value.isLoading = false
state.value.hasError = true
state.value.errorMessage = 'Failed to load image'
}
const zoom = (factor: number) => {
const newScale = state.value.scale * factor
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX))
}
const reset = () => {
state.value.scale = INITIAL_SCALE
state.value.translateX = 0
state.value.translateY = 0
}
const startPan = (e: MouseEvent) => {
state.value.isPanning = true
state.value.startX = e.clientX - state.value.translateX
state.value.startY = e.clientY - state.value.translateY
}
const handlePan = (e: MouseEvent) => {
if (!state.value.isPanning) return
cancelAnimationFrame(rafId.value)
rafId.value = requestAnimationFrame(() => {
state.value.translateX = e.clientX - state.value.startX
state.value.translateY = e.clientY - state.value.startY
})
}
const stopPan = () => {
state.value.isPanning = false
}
const handleWheel = (e: WheelEvent) => {
const delta = e.deltaY * -0.001
const factor = 1 + delta
zoom(factor)
}
watch(
() => props.imageBlob,
(newBlob) => {
if (!newBlob) return
state.value.isLoading = true
state.value.hasError = false
updateImageUrl(newBlob)
},
)
onMounted(() => {
if (props.imageBlob) updateImageUrl(props.imageBlob)
})
onUnmounted(() => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
cancelAnimationFrame(rafId.value)
})
</script>

View File

@@ -1,102 +0,0 @@
<template>
<div
aria-hidden="true"
class="sticky top-0 z-20 flex w-full select-none flex-row items-center justify-between border border-b-0 border-solid border-surface-3 bg-surface-3 p-4 text-sm font-medium transition-[border-radius] duration-100 before:pointer-events-none before:absolute before:inset-x-0 before:-top-5 before:h-5 before:bg-surface-3"
:class="isStuck ? 'rounded-none' : 'rounded-t-[20px]'"
>
<div class="flex flex-1 items-center gap-3">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected && !allSelected"
@update:model-value="$emit('toggle-all')"
/>
<button
class="flex appearance-none items-center gap-1.5 bg-transparent text-contrast hover:text-brand"
@click="$emit('sort', 'name')"
>
<span>Name</span>
<ChevronUpIcon
v-if="sortField === 'name' && !sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'name' && sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
</div>
<div class="flex shrink-0 items-center gap-4 md:gap-12">
<button
class="hidden w-[100px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
@click="$emit('sort', 'size')"
>
<span>Size</span>
<ChevronUpIcon
v-if="sortField === 'size' && !sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'size' && sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<button
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
@click="$emit('sort', 'created')"
>
<span>Created</span>
<ChevronUpIcon
v-if="sortField === 'created' && !sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'created' && sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<button
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
@click="$emit('sort', 'modified')"
>
<span>Modified</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<span class="w-[51px] text-right text-primary">Actions</span>
</div>
</div>
</template>
<script setup lang="ts">
import { Checkbox } from '@modrinth/ui'
import ChevronDownIcon from './icons/ChevronDownIcon.vue'
import ChevronUpIcon from './icons/ChevronUpIcon.vue'
defineProps<{
sortField: string
sortDesc: boolean
allSelected: boolean
someSelected: boolean
isStuck: boolean
}>()
defineEmits<{
(e: 'sort', field: string): void
(e: 'toggle-all'): void
}>()
</script>

View File

@@ -1,80 +0,0 @@
<template>
<NewModal ref="modal" :header="`Moving ${item?.name}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<StyledInput
ref="destinationInput"
v-model="destination"
autofocus
wrapper-class="bg-bg-input w-full rounded-lg p-4"
placeholder="e.g. /mods/modname"
/>
</div>
<div class="flex items-center gap-2 text-nowrap">
New location:
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
<span class="text-secondary">/root</span>{{ newpath }}
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button type="submit">
<ArrowBigUpDashIcon class="h-5 w-5" />
Move
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { ArrowBigUpDashIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const destinationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
item: { name: string } | null
currentPath: string
}>()
const emit = defineEmits<{
(e: 'move', destination: string): void
}>()
const modal = ref<typeof NewModal>()
const destination = ref('')
const newpath = computed(() => {
const path = destination.value.replace('//', '/')
return path.startsWith('/') ? path : `/${path}`
})
const handleSubmit = () => {
emit('move', newpath.value)
hide()
}
const show = () => {
destination.value = props.currentPath
modal.value?.show()
nextTick(() => {
setTimeout(() => {
destinationInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,92 +0,0 @@
<template>
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Name</div>
<StyledInput
ref="renameInput"
v-model="itemName"
autofocus
wrapper-class="bg-bg-input w-full rounded-lg p-4"
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!!error" type="submit">
<EditIcon class="h-5 w-5" />
Rename
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { EditIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const props = defineProps<{
item: { name: string; type: string } | null
}>()
const emit = defineEmits<{
(e: 'rename', newName: string): void
}>()
const modal = ref<typeof NewModal>()
const renameInput = ref<HTMLInputElement | null>(null)
const itemName = ref('')
const submitted = ref(false)
const error = computed(() => {
if (!itemName.value) {
return 'Name is required.'
}
if (props.item?.type === 'file') {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
}
}
return ''
})
const handleSubmit = () => {
submitted.value = true
if (!error.value) {
emit('rename', itemName.value)
hide()
}
}
const show = (item: { name: string; type: string }) => {
itemName.value = item.name
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
renameInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,56 +0,0 @@
<template>
<ConfirmModal
ref="modal"
title="Do you want to overwrite these conflicting files?"
:proceed-label="`Overwrite`"
:proceed-icon="CheckIcon"
@proceed="proceed"
>
<div class="flex max-w-[30rem] flex-col gap-4">
<p class="m-0 font-semibold leading-normal">
<template v-if="hasMany">
Over 100 files will be overwritten if you proceed with extraction; here is just some of
them:
</template>
<template v-else>
The following {{ files.length }} files already exist on your server, and will be
overwritten if you proceed with extraction:
</template>
</p>
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
<XIcon class="shrink-0 text-red" /> {{ file }}
</li>
</ul>
</div>
</ConfirmModal>
</template>
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import { ConfirmModal } from '@modrinth/ui'
import { ref } from 'vue'
const path = ref('')
const files = ref<string[]>([])
const emit = defineEmits<{
(e: 'proceed', path: string): void
}>()
const modal = ref<typeof ConfirmModal>()
const hasMany = computed(() => files.value.length > 100)
const show = (zipPath: string, conflictingFiles: string[]) => {
path.value = zipPath
files.value = conflictingFiles
modal.value?.show()
}
const proceed = () => {
emit('proceed', path.value)
}
defineExpose({ show })
</script>

View File

@@ -1,75 +0,0 @@
<template>
<div
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<slot />
<div
v-if="isDragging"
:class="[
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black/60 text-contrast shadow',
overlayClass,
]"
>
<div class="text-center">
<UploadIcon class="mx-auto h-16 w-16 shadow-2xl" />
<p class="mt-2 text-xl">
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { ref } from 'vue'
const emit = defineEmits<{
(event: 'filesDropped', files: File[]): void
}>()
defineProps<{
overlayClass?: string
type?: string
}>()
const isDragging = ref(false)
const dragCounter = ref(0)
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
dragCounter.value++
isDragging.value = true
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
dragCounter.value--
if (dragCounter.value === 0) {
isDragging.value = false
}
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragging.value = false
dragCounter.value = 0
const isInternalMove = event.dataTransfer?.types.includes('application/modrinth-file-move')
if (isInternalMove) return
const files = event.dataTransfer?.files
if (files) {
emit('filesDropped', Array.from(files))
}
}
</script>

View File

@@ -1,335 +0,0 @@
<template>
<div>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
v-bind="$attrs"
:class="['flex flex-col p-4 text-sm text-contrast']"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : 'File' }} uploads
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}</span>
</span>
</div>
</div>
<div class="mt-2 space-y-2">
<div
v-for="item in uploadQueue"
:key="item.file.name"
class="flex h-6 items-center justify-between gap-2 text-xs"
>
<div class="flex flex-1 items-center gap-2 truncate">
<transition-group name="status-icon" mode="out-in">
<PanelSpinner
v-show="item.status === 'uploading'"
key="spinner"
class="absolute !size-4"
/>
<CheckCircleIcon
v-show="item.status === 'completed'"
key="check"
class="absolute size-4 text-green"
/>
<XCircleIcon
v-show="
item.status.includes('error') ||
item.status === 'cancelled' ||
item.status === 'incorrect-type'
"
key="error"
class="absolute size-4 text-red"
/>
</transition-group>
<span class="ml-6 truncate">{{ item.file.name }}</span>
<span class="text-secondary">{{ item.size }}</span>
</div>
<div class="flex min-w-[80px] items-center justify-end gap-2">
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'error-file-exists'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'error-generic'">
<span class="text-red"
>Failed - {{ item.error?.message || 'An unexpected error occured.' }}</span
>
</template>
<template v-else-if="item.status === 'incorrect-type'">
<span class="text-red">Failed - Incorrect file type</span>
</template>
<template v-else>
<template v-if="item.status === 'uploading'">
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
<button>Cancel</button>
</ButtonStyled>
</template>
<template v-else-if="item.status === 'cancelled'">
<span class="text-red">Cancelled</span>
</template>
<template v-else>
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { computed, nextTick, ref, watch } from 'vue'
import PanelSpinner from './PanelSpinner.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
interface UploadItem {
file: File
progress: number
status:
| 'pending'
| 'uploading'
| 'completed'
| 'error-file-exists'
| 'error-generic'
| 'cancelled'
| 'incorrect-type'
size: string
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
error?: Error
}
interface Props {
currentPath: string
fileType?: string
marginBottom?: number
acceptedTypes?: Array<string>
}
defineOptions({
inheritAttrs: false,
})
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'uploadComplete'): void
}>()
const uploadStatusRef = ref<HTMLElement | null>(null)
const statusContentRef = ref<HTMLElement | null>(null)
const uploadQueue = ref<UploadItem[]>([])
const isUploading = computed(() => uploadQueue.value.length > 0)
const activeUploads = computed(() =>
uploadQueue.value.filter((item) => item.status === 'pending' || item.status === 'uploading'),
)
const onUploadStatusEnter = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
;(el as HTMLElement).style.height = '0'
void (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.height = `${height}px`
}
const onUploadStatusLeave = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
;(el as HTMLElement).style.height = `${height}px`
void (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.height = '0'
}
watch(
uploadQueue,
() => {
if (!uploadStatusRef.value) return
const el = uploadStatusRef.value
const itemsHeight = uploadQueue.value.length * 32
const headerHeight = 12
const gap = 8
const padding = 32
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0)
el.style.height = `${totalHeight}px`
},
{ deep: true },
)
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
}
const cancelUpload = (item: UploadItem) => {
if (item.uploader && item.status === 'uploading') {
item.uploader.cancel()
item.status = 'cancelled'
setTimeout(async () => {
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name)
if (index !== -1) {
uploadQueue.value.splice(index, 1)
await nextTick()
}
}, 5000)
}
}
const badFileTypeMsg = 'Upload had incorrect file type'
const uploadFile = async (file: File) => {
const uploadItem: UploadItem = {
file,
progress: 0,
status: 'pending',
size: formatFileSize(file.size),
}
uploadQueue.value.push(uploadItem)
try {
if (
props.acceptedTypes &&
!props.acceptedTypes.includes(file.type) &&
!props.acceptedTypes.some((type) => file.name.endsWith(type))
) {
throw new Error(badFileTypeMsg)
}
uploadItem.status = 'uploading'
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
onProgress: ({ progress }) => {
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1) {
uploadQueue.value[index].progress = Math.round(progress)
}
},
})
uploadItem.uploader = uploader
await uploader.promise
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
uploadQueue.value[index].status = 'completed'
uploadQueue.value[index].progress = 100
}
await nextTick()
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1)
await nextTick()
}
}, 5000)
emit('uploadComplete')
} catch (error) {
console.error('Error uploading file:', error)
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
const target = uploadQueue.value[index]
if (error instanceof Error) {
if (error.message === badFileTypeMsg) {
target.status = 'incorrect-type'
} else if (target.progress === 100 && error.message.includes('401')) {
target.status = 'error-file-exists'
} else {
target.status = 'error-generic'
target.error = error
}
}
}
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1)
await nextTick()
}
}, 5000)
if (error instanceof Error && error.message !== 'Upload cancelled') {
addNotification({
title: 'Upload failed',
text: `Failed to upload ${file.name}`,
type: 'error',
})
}
}
}
defineExpose({
uploadFile,
cancelUpload,
})
</script>
<style scoped>
.upload-status {
overflow: hidden;
transition: height 0.2s ease;
}
.upload-status-enter-active,
.upload-status-leave-active {
transition: height 0.2s ease;
overflow: hidden;
}
.upload-status-enter-from,
.upload-status-leave-to {
height: 0 !important;
}
.status-icon-enter-active,
.status-icon-leave-active {
transition: all 0.25s ease;
}
.status-icon-enter-from,
.status-icon-leave-to {
transform: scale(0);
opacity: 0;
}
.status-icon-enter-to,
.status-icon-leave-from {
transform: scale(1);
opacity: 1;
}
</style>

View File

@@ -1,162 +0,0 @@
<template>
<NewModal
ref="modal"
:header="cf ? `Installing a CurseForge pack` : `Uploading .zip contents from URL`"
>
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-bold text-contrast">
{{ cf ? `How to get the modpack version's URL` : 'URL of .zip file' }}
</div>
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
<li>
<a
href="https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks"
class="inline-flex font-semibold text-[#F16436] transition-all hover:underline active:brightness-[--hover-brightness]"
target="_blank"
rel="noopener noreferrer"
>
Find the CurseForge modpack
<ExternalIcon class="ml-1 inline size-4" stroke-width="3" />
</a>
you'd like to install on your server.
</li>
<li>
On the modpack's page, go to the
<span class="font-semibold text-primary">"Files"</span> tab, and
<span class="font-semibold text-primary">select the version</span> of the modpack you
want to install.
</li>
<li>
<span class="font-semibold text-primary">Copy the URL</span> of the version you want to
install, and paste it in the box below.
</li>
</ol>
<p v-else class="mb-1 mt-0">Copy and paste the direct download URL of a .zip file.</p>
<StyledInput
ref="urlInput"
v-model="url"
autofocus
:disabled="submitted"
data-1p-ignore
data-lpignore="true"
data-protonpass-ignore="true"
:placeholder="
cf
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
autocomplete="off"
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<BackupWarning :backup-link="`/hosting/manage/${serverId}/backups`" />
<div class="flex justify-start gap-2">
<ButtonStyled color="brand">
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
<SpinnerIcon v-if="submitted" class="animate-spin" />
<DownloadIcon v-else class="h-5 w-5" />
{{ submitted ? 'Installing...' : 'Install' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
{{ submitted ? 'Close' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
BackupWarning,
ButtonStyled,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
StyledInput,
} from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
import { handleServersError } from '~/composables/servers/modrinth-servers.ts'
const notifications = injectNotificationManager()
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const cf = ref(false)
const modal = ref<typeof NewModal>()
const urlInput = ref<HTMLInputElement | null>(null)
const url = ref('')
const submitted = ref(false)
const trimmedUrl = computed(() => url.value.trim())
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/
const error = computed(() => {
if (trimmedUrl.value.length === 0) {
return 'URL is required.'
}
if (cf.value && !regex.test(trimmedUrl.value)) {
return 'URL must be a CurseForge modpack version URL.'
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
return 'URL must be valid.'
}
return ''
})
const handleSubmit = async () => {
submitted.value = true
if (!error.value) {
// hide();
try {
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
if (!cf.value || dry.modpack_name) {
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
hide()
} else {
submitted.value = false
handleServersError(
new ModrinthServersFetchError(
'Could not find CurseForge modpack at that URL.',
404,
new Error(`No modpack found at ${url.value}`),
),
notifications,
)
}
} catch (error) {
submitted.value = false
console.error('Error installing:', error)
handleServersError(error, notifications)
}
}
}
const show = (isCf: boolean) => {
cf.value = isCf
url.value = ''
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
urlInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,76 +0,0 @@
<template>
<div class="ticker-container">
<div class="ticker-content">
<div
v-for="(message, index) in msgs"
:key="message"
class="ticker-item text-xs"
:class="{ active: index === currentIndex % msgs.length }"
>
{{ message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const msgs = [
'Organizing files...',
'Downloading mods...',
'Configuring server...',
'Setting up environment...',
'Adding Java...',
]
const currentIndex = ref(0)
let intervalId: NodeJS.Timeout | null = null
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % msgs.length
}, 3000)
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId)
}
})
</script>
<style scoped>
.ticker-container {
height: 20px;
width: 100%;
position: relative;
}
.ticker-content {
position: relative;
width: 100%;
}
.ticker-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
color: var(--color-secondary-text);
opacity: 0;
transform: scale(0.9);
filter: blur(4px);
transition: all 0.3s ease-in-out;
}
.ticker-item.active {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
</style>

View File

@@ -68,11 +68,7 @@
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!canTakeAction"
@click="handlePrimaryAction"
>
<button v-tooltip="busyReason" :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitionState" class="grid place-content-center">
<LoadingIcon />
</div>
@@ -120,20 +116,17 @@ import {
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels, useVIntl } from '@modrinth/ui'
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels } from '@modrinth/ui'
import type { PowerAction as ServerPowerAction, ServerState } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import LoadingIcon from './icons/LoadingIcon.vue'
import PanelSpinner from './PanelSpinner.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
interface PowerAction {
action: ServerPowerAction
@@ -148,7 +141,7 @@ const props = defineProps<{
serverName?: string
serverData: object
uptimeSeconds: number
backupInProgress?: BackupInProgressReason
busyReason?: string
}>()
const emit = defineEmits<{
@@ -170,11 +163,7 @@ const dontAskAgain = ref(false)
const startingDelay = ref(false)
const canTakeAction = computed(
() =>
!props.isActioning &&
!startingDelay.value &&
!isTransitionState.value &&
!props.backupInProgress,
() => !props.isActioning && !startingDelay.value && !isTransitionState.value && !props.busyReason,
)
const isRunning = computed(() => serverState.value === 'running')
const isTransitionState = computed(() =>

View File

@@ -64,15 +64,22 @@
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectNotificationManager, NewModal, Toggle } from '@modrinth/ui'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
Toggle,
} from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { serverId } = injectModrinthServerContext()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
project: any
versions: any[]
currentVersion?: any
@@ -98,11 +105,12 @@ const handleReinstall = async () => {
try {
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id
await props.server.general.reinstall(
false,
props.project.id,
versionId,
undefined,
await client.archon.servers_v0.reinstall(
serverId,
{
project_id: props.project.id,
version_id: versionId,
},
hardReset.value,
)

View File

@@ -1,253 +0,0 @@
<template>
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]">
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="!isLoading" class="flex flex-col gap-4">
<p
v-if="isMrpackModalSecondPhase"
:style="{
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
}"
>
This will reinstall your server and erase all data. You may want to back up your server
before proceeding. Are you sure you want to continue?
</p>
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UploadIcon class="size-10" />
</div>
<ArrowBigRightDashIcon class="size-10" />
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<Toggle id="hard-reset" v-model="hardReset" class="shrink-0" />
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration
files, then reinstalls it with the selected version.
</div>
<div class="font-bold">
This does not affect your backups, which are stored off-site.
</div>
</div>
<BackupWarning :backup-link="`/hosting/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isMrpackModalSecondPhase
? 'Erase and install'
: loadingServerCheck
? 'Loading...'
: isDangerous
? 'Erase and install'
: 'Install'
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false
} else {
hide()
}
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? 'Go back' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
</div>
</Transition>
</div>
</NewModal>
</template>
<script setup lang="ts">
import {
ArrowBigRightDashIcon,
RightArrowIcon,
ServerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
AppearingProgressBar,
BackupWarning,
ButtonStyled,
injectNotificationManager,
NewModal,
Toggle,
} from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import { onMounted, onUnmounted } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
const { addNotification } = injectNotificationManager()
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isLoading.value) {
event.preventDefault()
return 'Upload in progress. Are you sure you want to leave?'
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
const props = defineProps<{
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
reinstall: [any?]
}>()
const mrpackModal = ref()
const isMrpackModalSecondPhase = ref(false)
const hardReset = ref(false)
const isLoading = ref(false)
const loadingServerCheck = ref(false)
const mrpackFile = ref<File | null>(null)
const uploadedBytes = ref(0)
const totalBytes = ref(0)
const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value)
const uploadMrpack = (event: Event) => {
const target = event.target as HTMLInputElement
if (!target.files || target.files.length === 0) {
return
}
mrpackFile.value = target.files[0]
}
const handleReinstall = async () => {
if (hardReset.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true
return
}
if (!mrpackFile.value) {
addNotification({
title: 'No file selected',
text: 'Choose a .mrpack file before installing.',
type: 'error',
})
return
}
isLoading.value = true
uploadedBytes.value = 0
totalBytes.value = mrpackFile.value.size
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
)
onProgress(({ loaded, total }) => {
uploadedBytes.value = loaded
totalBytes.value = total
})
try {
await promise
emit('reinstall', {
loader: 'mrpack',
lVersion: '',
mVersion: '',
})
await nextTick()
window.scrollTo(0, 0)
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
title: 'Cannot upload and install modpack to server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
title: 'Modpack upload and install failed',
text: 'An unexpected error occurred while uploading/installing. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false
}
}
const onShow = () => {
hardReset.value = false
isMrpackModalSecondPhase.value = false
loadingServerCheck.value = false
isLoading.value = false
mrpackFile.value = null
uploadedBytes.value = 0
totalBytes.value = 0
}
const show = () => mrpackModal.value?.show()
const hide = () => mrpackModal.value?.hide()
defineExpose({ show, hide })
</script>

View File

@@ -152,17 +152,14 @@
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning
v-if="!initialSetup"
:backup-link="`/hosting/manage/${props.server?.serverId}/backups`"
/>
<BackupWarning v-if="!initialSetup" :backup-link="`/hosting/manage/${serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || !!backupInProgress"
v-tooltip="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
:disabled="canInstall || busyReasons.length > 0"
@click="handleReinstall"
>
<RightArrowIcon />
@@ -205,6 +202,8 @@ import {
BackupWarning,
ButtonStyled,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
Toggle,
@@ -213,12 +212,11 @@ import {
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from 'ofetch'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import LoaderIcon from './icons/LoaderIcon.vue'
import LoadingIcon from './icons/LoadingIcon.vue'
const { server, serverId, busyReasons } = injectModrinthServerContext()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -236,9 +234,7 @@ type VersionMap = Record<string, LoaderVersion[]>
type VersionCache = Record<string, any>
const props = defineProps<{
server: ModrinthServer
currentLoader: Loaders | undefined
backupInProgress?: BackupInProgressReason
initialSetup?: boolean
}>()
@@ -472,11 +468,14 @@ const handleReinstall = async () => {
isLoading.value = true
try {
await props.server.general?.reinstall(
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === 'Vanilla' ? '' : selectedLoaderVersion.value,
await client.archon.servers_v0.reinstall(
serverId,
{
loader: selectedLoader.value,
loader_version:
selectedLoader.value === 'Vanilla' ? undefined : selectedLoaderVersion.value || undefined,
game_version: selectedMCVersion.value,
},
props.initialSetup ? true : hardReset.value,
)
@@ -507,7 +506,7 @@ const handleReinstall = async () => {
}
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || ''
selectedMCVersion.value = server.value?.mc_version || ''
if (isSnapshotSelected.value) {
showSnapshots.value = true
}
@@ -530,7 +529,7 @@ const show = (loader: Loaders) => {
selectedLoaderVersion.value = ''
}
selectedLoader.value = loader
selectedMCVersion.value = props.server.general?.mc_version || ''
selectedMCVersion.value = server.value?.mc_version || ''
versionSelectModal.value?.show()
}
const hide = () => versionSelectModal.value?.hide()

View File

@@ -30,9 +30,7 @@
</template>
<script setup lang="ts">
import { ButtonStyled } from '@modrinth/ui'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui'
const props = defineProps<{
isUpdating: boolean
@@ -40,12 +38,14 @@ const props = defineProps<{
save: () => void
reset: () => void
isVisible: boolean
server: ModrinthServer
serverId: string
}>()
const client = injectModrinthClient()
const saveAndRestart = async () => {
props.save()
await props.server.general?.power('Restart')
await client.archon.servers_v0.power(props.serverId, 'Restart')
}
</script>

View File

@@ -1,286 +0,0 @@
<template>
<PlatformVersionSelectModal
ref="versionSelectModal"
:server="props.server"
:current-loader="ignoreCurrentInstallation ? undefined : (data?.loader as Loaders)"
:backup-in-progress="backupInProgress"
:initial-setup="ignoreCurrentInstallation"
@reinstall="emit('reinstall', $event)"
/>
<PlatformMrpackModal
ref="mrpackModal"
:server="props.server"
@reinstall="emit('reinstall', $event)"
/>
<PlatformChangeModpackVersionModal
ref="modpackVersionModal"
:server="props.server"
:project="data?.project"
:versions="Array.isArray(versions) ? versions : []"
:current-version="currentVersion"
:current-version-id="data?.upstream?.version_id"
:server-status="data?.status"
@reinstall="emit('reinstall')"
/>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div
v-if="updateAvailable"
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
>
<span>Update available</span>
</div>
</div>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="isInstalling"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Import .mrpack
</button>
</ButtonStyled>
<!-- dumb hack to make a button link not a link -->
<ButtonStyled>
<template v-if="isInstalling">
<button :disabled="isInstalling">
<TransferIcon class="size-4" />
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/discover/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="flex flex-col gap-2">
<div
v-if="versionsError || currentVersionError"
class="rounded-2xl border border-solid border-red p-4 text-contrast"
>
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
<p class="m-0 mb-2 mt-1 text-sm">
{{ versionsError || currentVersionError }}
</p>
<ButtonStyled>
<button :disabled="isInstalling" @click="refreshData">Retry</button>
</ButtonStyled>
</div>
<ProjectCard
v-if="!versionsError && !currentVersionError"
class="!bg-bg"
:title="projectCardData.title"
:icon-url="projectCardData.icon_url"
:date-updated="projectCardData.date_modified"
:followers="projectCardData.follows"
:downloads="projectCardData.downloads"
layout="list"
:summary="projectCardData.description"
:tags="data.project?.categories || []"
>
<template #actions>
<ButtonStyled color="brand">
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
<SettingsIcon class="size-4" />
Change version
</button>
</ButtonStyled>
</template>
</ProjectCard>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/discover/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
The current platform was automatically selected based on your modpack.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing',
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<LoaderSelector
:data="
ignoreCurrentInstallation
? {
loader: null,
loader_version: null,
}
: data
"
:is-installing="isInstalling"
@select-loader="selectLoader"
/>
</div>
</div>
</div>
<div v-else />
</div>
</template>
<script setup lang="ts">
import { CompassIcon, InfoIcon, SettingsIcon, TransferIcon, UploadIcon } from '@modrinth/assets'
import { ButtonStyled, ProjectCard, useVIntl } from '@modrinth/ui'
import type { Loaders } from '@modrinth/utils'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import LoaderSelector from './LoaderSelector.vue'
import PlatformChangeModpackVersionModal from './PlatformChangeModpackVersionModal.vue'
import PlatformMrpackModal from './PlatformMrpackModal.vue'
import PlatformVersionSelectModal from './PlatformVersionSelectModal.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
server: ModrinthServer
ignoreCurrentInstallation?: boolean
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
reinstall: [any?]
}>()
const isInstalling = computed(() => props.server.general?.status === 'installing')
const versionSelectModal = ref()
const mrpackModal = ref()
const modpackVersionModal = ref()
const data = computed(() => props.server.general)
const {
data: versions,
error: versionsError,
refresh: refreshVersions,
} = await useAsyncData(
`content-loader-versions-${data.value?.upstream?.project_id}`,
async () => {
if (!data.value?.upstream?.project_id) return []
try {
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`)
return result || []
} catch (e) {
console.error('couldnt fetch all versions:', e)
throw new Error('Failed to load modpack versions.')
}
},
{ default: () => [] },
)
const {
data: currentVersion,
error: currentVersionError,
refresh: refreshCurrentVersion,
} = await useAsyncData(
`content-loader-version-${data.value?.upstream?.version_id}`,
async () => {
if (!data.value?.upstream?.version_id) return null
try {
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`)
return result || null
} catch (e) {
console.error('couldnt fetch version:', e)
throw new Error('Failed to load modpack version.')
}
},
{ default: () => null },
)
const projectCardData = computed(() => ({
icon_url: data.value?.project?.icon_url,
title: data.value?.project?.title,
description: data.value?.project?.description,
downloads: data.value?.project?.downloads,
follows: data.value?.project?.followers,
// @ts-ignore
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
}))
const selectLoader = (loader: string) => {
versionSelectModal.value?.show(loader as Loaders)
}
const refreshData = async () => {
await Promise.all([refreshVersions(), refreshCurrentVersion()])
}
const updateAvailable = computed(() => {
// so sorry
// @ts-ignore
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
return false
}
// @ts-ignore
const latestVersion = versions.value[0]
// @ts-ignore
return latestVersion.id !== currentVersion.value.id
})
watch(
() => props.server.general?.status,
async (newStatus, oldStatus) => {
if (oldStatus === 'installing' && newStatus === 'available') {
await Promise.all([
refreshVersions(),
refreshCurrentVersion(),
props.server.refresh(['general']),
])
}
},
)
</script>
<style scoped>
.button-base:active {
scale: none !important;
}
</style>

View File

@@ -24,12 +24,7 @@
</div>
<div class="h-full w-full">
<NuxtPage
:route="route"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
<NuxtPage :route="route" @reinstall="onReinstall" />
</div>
</div>
</template>
@@ -38,9 +33,6 @@
import { RightArrowIcon } from '@modrinth/assets'
import type { RouteLocationNormalized } from 'vue-router'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
const emit = defineEmits(['reinstall'])
defineProps<{
@@ -52,8 +44,6 @@ defineProps<{
shown?: boolean
}[]
route: RouteLocationNormalized
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const onReinstall = (...args: any[]) => {

View File

@@ -43,6 +43,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
hidePreviewBanner: false,
i18nDebug: false,
showDiscoverProjectButtons: false,
useV1ContentTabAPI: true,
labrinthApiCanary: false,
} as const)

View File

@@ -1,287 +0,0 @@
import type { AbstractWebNotificationManager } from '@modrinth/ui'
import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
import { ModrinthServerError } from '@modrinth/utils'
import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
import { useServersFetch } from './servers-fetch.ts'
export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
if (err instanceof ModrinthServerError && err.v1Error) {
notifications.addNotification({
title: err.v1Error?.context ?? `An error occurred`,
type: 'error',
text: err.v1Error.description,
errorCode: err.v1Error.error,
})
} else {
notifications.addNotification({
title: 'An error occurred',
type: 'error',
text: err.message ?? (err.data ? err.data.description : err),
})
}
}
export class ModrinthServer {
readonly serverId: string
private errors: Partial<Record<ModuleName, ModuleError>> = {}
readonly general: GeneralModule
readonly content: ContentModule
readonly network: NetworkModule
readonly startup: StartupModule
constructor(serverId: string) {
this.serverId = serverId
this.general = new GeneralModule(this)
this.content = new ContentModule(this)
this.network = new NetworkModule(this)
this.startup = new StartupModule(this)
}
async fetchConfigFile(fileName: string): Promise<any> {
return await useServersFetch(`servers/${this.serverId}/config/${fileName}`)
}
constructServerProperties(properties: any): string {
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
for (const [key, value] of Object.entries(properties)) {
if (typeof value === 'object') {
fileContent += `${key}=${JSON.stringify(value)}\n`
} else if (typeof value === 'boolean') {
fileContent += `${key}=${value ? 'true' : 'false'}\n`
} else {
fileContent += `${key}=${value}\n`
}
}
return fileContent
}
async processImage(iconUrl: string | undefined): Promise<string | undefined> {
const sharedImage = useState<string | undefined>(`server-icon-${this.serverId}`)
if (sharedImage.value) {
return sharedImage.value
}
try {
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`)
try {
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
override: auth,
retry: 1, // Reduce retries for optional resources
})
if (fileData instanceof Blob && import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = 512
canvas.height = 512
ctx?.drawImage(img, 0, 0, 512, 512)
const dataURL = canvas.toDataURL('image/png')
sharedImage.value = dataURL
resolve(dataURL)
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(fileData)
})
return dataURL
}
} catch (error) {
if (error instanceof ModrinthServerError) {
if (error.statusCode && error.statusCode >= 500) {
console.debug('Service unavailable, skipping icon processing')
sharedImage.value = undefined
return undefined
}
if (error.statusCode === 404 && iconUrl) {
try {
const response = await fetch(iconUrl)
if (!response.ok) throw new Error('Failed to fetch icon')
const file = await response.blob()
const originalFile = new File([file], 'server-icon-original.png', {
type: 'image/png',
})
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = 64
canvas.height = 64
ctx?.drawImage(img, 0, 0, 64, 64)
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], 'server-icon.png', {
type: 'image/png',
})
await useServersFetch(`/create?path=/server-icon.png&type=file`, {
method: 'POST',
contentType: 'application/octet-stream',
body: scaledFile,
override: auth,
})
await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
method: 'POST',
contentType: 'application/octet-stream',
body: originalFile,
override: auth,
})
}
}, 'image/png')
const dataURL = canvas.toDataURL('image/png')
sharedImage.value = dataURL
resolve(dataURL)
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(file)
})
return dataURL
}
} catch (externalError: any) {
console.debug('Could not process external icon:', externalError.message)
}
}
} else {
throw error
}
}
} catch (error: any) {
console.debug('Icon processing failed:', error.message)
}
sharedImage.value = undefined
return undefined
}
async testNodeReachability(): Promise<boolean> {
if (!this.general?.node?.instance) {
console.warn('No node instance available for ping test')
return false
}
const wsUrl = `wss://${this.general.node.instance}/pingtest`
try {
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
socket.close()
resolve(false)
}, 5000)
socket.onopen = () => {
clearTimeout(timeout)
socket.send(performance.now().toString())
}
socket.onmessage = () => {
clearTimeout(timeout)
socket.close()
resolve(true)
}
socket.onerror = () => {
clearTimeout(timeout)
resolve(false)
}
})
} catch (error) {
console.error(`Failed to ping node ${wsUrl}:`, error)
return false
}
}
async refresh(
modules: ModuleName[] = [],
options?: {
preserveConnection?: boolean
preserveInstallState?: boolean
},
): Promise<void> {
const modulesToRefresh =
modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
for (const module of modulesToRefresh) {
this.errors[module] = undefined
try {
switch (module) {
case 'general': {
if (options?.preserveConnection) {
const currentImage = this.general.image
const currentMotd = this.general.motd
const currentStatus = this.general.status
await this.general.fetch()
if (currentImage) {
this.general.image = currentImage
}
if (currentMotd) {
this.general.motd = currentMotd
}
if (options.preserveInstallState && currentStatus === 'installing') {
this.general.status = 'installing'
}
} else {
await this.general.fetch()
}
break
}
case 'content':
await this.content.fetch()
break
case 'network':
await this.network.fetch()
break
case 'startup':
await this.startup.fetch()
break
}
} catch (error) {
if (error instanceof ModrinthServerError) {
if (error.statusCode === 404 && module === 'content') {
console.debug(`Optional ${module} resource not found:`, error.message)
continue
}
if (error.statusCode && error.statusCode >= 500) {
console.debug(`Temporary ${module} unavailable:`, error.message)
continue
}
}
this.errors[module] = {
error:
error instanceof ModrinthServerError
? error
: new ModrinthServerError('Unknown error', undefined, error as Error),
timestamp: Date.now(),
}
}
}
}
get moduleErrors() {
return this.errors
}
}
export const useModrinthServers = async (
serverId: string,
includedModules: ModuleName[] = ['general'],
) => {
const server = new ModrinthServer(serverId)
await server.refresh(includedModules)
return reactive(server)
}

View File

@@ -1,109 +0,0 @@
import type { AutoBackupSettings, Backup } from '@modrinth/utils'
import { useServersFetch } from '../servers-fetch.ts'
import { ServerModule } from './base.ts'
export class BackupsModule extends ServerModule {
data: Backup[] = []
async fetch(): Promise<void> {
this.data = await useServersFetch<Backup[]>(`servers/${this.serverId}/backups`, {}, 'backups')
}
async create(backupName: string): Promise<string> {
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
const tempBackup: Backup = {
id: tempId,
name: backupName,
created_at: new Date().toISOString(),
locked: false,
automated: false,
interrupted: false,
ongoing: true,
task: { create: { progress: 0, state: 'ongoing' } },
}
this.data.push(tempBackup)
try {
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
method: 'POST',
body: { name: backupName },
})
const backup = this.data.find((b) => b.id === tempId)
if (backup) {
backup.id = response.id
}
return response.id
} catch (error) {
this.data = this.data.filter((b) => b.id !== tempId)
throw error
}
}
async rename(backupId: string, newName: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/rename`, {
method: 'POST',
body: { name: newName },
})
await this.fetch()
}
async delete(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
method: 'DELETE',
})
await this.fetch()
}
async restore(backupId: string): Promise<void> {
const backup = this.data.find((b) => b.id === backupId)
if (backup) {
if (!backup.task) backup.task = {}
backup.task.restore = { progress: 0, state: 'ongoing' }
}
try {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
method: 'POST',
})
} catch (error) {
if (backup?.task?.restore) {
delete backup.task.restore
}
throw error
}
}
async lock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
method: 'POST',
})
await this.fetch()
}
async unlock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
method: 'POST',
})
await this.fetch()
}
async retry(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/retry`, {
method: 'POST',
})
}
async updateAutoBackup(autoBackup: 'enable' | 'disable', interval: number): Promise<void> {
await useServersFetch(`servers/${this.serverId}/autobackup`, {
method: 'POST',
body: { set: autoBackup, interval },
})
}
async getAutoBackup(): Promise<AutoBackupSettings> {
return await useServersFetch(`servers/${this.serverId}/autobackup`)
}
}

View File

@@ -1,15 +0,0 @@
import type { ModrinthServer } from '../modrinth-servers.ts'
export abstract class ServerModule {
protected server: ModrinthServer
constructor(server: ModrinthServer) {
this.server = server
}
protected get serverId(): string {
return this.server.serverId
}
abstract fetch(): Promise<void>
}

View File

@@ -1,37 +0,0 @@
import type { ContentType, Mod } from '@modrinth/utils'
import { useServersFetch } from '../servers-fetch.ts'
import { ServerModule } from './base.ts'
export class ContentModule extends ServerModule {
data: Mod[] = []
async fetch(): Promise<void> {
const mods = await useServersFetch<Mod[]>(`servers/${this.serverId}/mods`, {}, 'content')
this.data = mods.sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? ''))
}
async install(contentType: ContentType, projectId: string, versionId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/mods`, {
method: 'POST',
body: {
rinth_ids: { project_id: projectId, version_id: versionId },
install_as: contentType,
},
})
}
async remove(path: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/deleteMod`, {
method: 'POST',
body: { path },
})
}
async reinstall(replace: string, projectId: string, versionId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/mods/update`, {
method: 'POST',
body: { replace, project_id: projectId, version_id: versionId },
})
}
}

View File

@@ -1,201 +0,0 @@
import type { JWTAuth, PowerAction, Project, ServerGeneral } from '@modrinth/utils'
import { $fetch } from 'ofetch'
import { useServersFetch } from '../servers-fetch.ts'
import { ServerModule } from './base.ts'
export class GeneralModule extends ServerModule implements ServerGeneral {
server_id!: string
name!: string
owner_id!: string
net!: { ip: string; port: number; domain: string }
game!: string
backup_quota!: number
used_backup_quota!: number
status!: string
suspension_reason!: string
loader!: string
loader_version!: string
mc_version!: string
upstream!: {
kind: 'modpack' | 'mod' | 'resourcepack'
version_id: string
project_id: string
} | null
motd?: string
image?: string
project?: Project
sftp_username!: string
sftp_password!: string
sftp_host!: string
datacenter?: string
notices?: any[]
node!: { token: string; instance: string }
flows?: { intro?: boolean }
is_medal?: boolean
async fetch(): Promise<void> {
const data = await useServersFetch<ServerGeneral>(`servers/${this.serverId}`, {}, 'general')
if (data.upstream?.project_id) {
const project = await $fetch(
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
)
data.project = project as Project
}
if (import.meta.client) {
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined
}
// Copy data to this module
Object.assign(this, data)
}
async updateName(newName: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/name`, {
method: 'POST',
body: { name: newName },
})
}
async power(action: PowerAction): Promise<void> {
await useServersFetch(`servers/${this.serverId}/power`, {
method: 'POST',
body: { action },
})
await new Promise((resolve) => setTimeout(resolve, 1000))
await this.fetch() // Refresh this module
}
async reinstall(
loader: boolean,
projectId: string,
versionId?: string,
loaderVersionId?: string,
hardReset: boolean = false,
): Promise<void> {
const hardResetParam = hardReset ? 'true' : 'false'
if (loader) {
if (projectId.toLowerCase() === 'neoforge') {
projectId = 'NeoForge'
}
await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
method: 'POST',
body: {
loader: projectId,
loader_version: loaderVersionId,
game_version: versionId,
},
})
} else {
await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
method: 'POST',
body: { project_id: projectId, version_id: versionId },
})
}
}
reinstallFromMrpack(
mrpack: File,
hardReset: boolean = false,
): {
promise: Promise<void>
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void
} {
const hardResetParam = hardReset ? 'true' : 'false'
const progressSubject = new EventTarget()
const uploadPromise = (async () => {
try {
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
progressSubject.dispatchEvent(
new CustomEvent('progress', {
detail: {
loaded: e.loaded,
total: e.total,
progress: (e.loaded / e.total) * 100,
},
}),
)
}
})
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`))
xhr.onerror = () => reject(new Error('[pyroservers] .mrpack upload failed'))
xhr.onabort = () => reject(new Error('[pyroservers] .mrpack upload cancelled'))
xhr.ontimeout = () => reject(new Error('[pyroservers] .mrpack upload timed out'))
xhr.timeout = 30 * 60 * 1000
xhr.open('POST', `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`)
xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`)
const formData = new FormData()
formData.append('file', mrpack)
xhr.send(formData)
})
} catch (err) {
console.error('Error reinstalling from mrpack:', err)
throw err
}
})()
return {
promise: uploadPromise,
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
progressSubject.addEventListener('progress', ((e: CustomEvent) =>
cb(e.detail)) as EventListener),
}
}
async suspend(status: boolean): Promise<void> {
await useServersFetch(`servers/${this.serverId}/suspend`, {
method: 'POST',
body: { suspended: status },
})
}
async endIntro(): Promise<void> {
await useServersFetch(`servers/${this.serverId}/flows/intro`, {
method: 'DELETE',
version: 1,
})
await this.fetch() // Refresh this module
}
async setMotd(motd: string): Promise<void> {
try {
const props = (await this.server.fetchConfigFile('ServerProperties')) as any
if (props) {
props.motd = motd
const newProps = this.server.constructServerProperties(props)
const octetStream = new Blob([newProps], { type: 'application/octet-stream' })
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`)
await useServersFetch(`/update?path=/server.properties`, {
method: 'PUT',
contentType: 'application/octet-stream',
body: octetStream,
override: auth,
})
}
} catch {
console.error(
'[Modrinth Hosting] [General] Failed to set MOTD due to lack of server properties file.',
)
}
}
}

View File

@@ -1,7 +0,0 @@
export * from './backups.ts'
export * from './base.ts'
export * from './content.ts'
export * from './general.ts'
export * from './network.ts'
export * from './startup.ts'
export * from './ws.ts'

View File

@@ -1,48 +0,0 @@
import type { Allocation } from '@modrinth/utils'
import { useServersFetch } from '../servers-fetch.ts'
import { ServerModule } from './base.ts'
export class NetworkModule extends ServerModule {
allocations: Allocation[] = []
async fetch(): Promise<void> {
this.allocations = await useServersFetch<Allocation[]>(
`servers/${this.serverId}/allocations`,
{},
'network',
)
}
async reserveAllocation(name: string): Promise<Allocation> {
return await useServersFetch<Allocation>(`servers/${this.serverId}/allocations?name=${name}`, {
method: 'POST',
})
}
async updateAllocation(port: number, name: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/allocations/${port}?name=${name}`, {
method: 'PUT',
})
}
async deleteAllocation(port: number): Promise<void> {
await useServersFetch(`servers/${this.serverId}/allocations/${port}`, {
method: 'DELETE',
})
}
async checkSubdomainAvailability(subdomain: string): Promise<boolean> {
const result = (await useServersFetch(`subdomains/${subdomain}/isavailable`)) as {
available: boolean
}
return result.available
}
async changeSubdomain(subdomain: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/subdomain`, {
method: 'POST',
body: { subdomain },
})
}
}

View File

@@ -1,27 +0,0 @@
import type { JDKBuild, JDKVersion, Startup } from '@modrinth/utils'
import { useServersFetch } from '../servers-fetch.ts'
import { ServerModule } from './base.ts'
export class StartupModule extends ServerModule implements Startup {
invocation!: string
original_invocation!: string
jdk_version!: JDKVersion
jdk_build!: JDKBuild
async fetch(): Promise<void> {
const data = await useServersFetch<Startup>(`servers/${this.serverId}/startup`, {}, 'startup')
Object.assign(this, data)
}
async update(invocation: string, jdkVersion: JDKVersion, jdkBuild: JDKBuild): Promise<void> {
await useServersFetch(`servers/${this.serverId}/startup`, {
method: 'POST',
body: {
invocation: invocation || null,
jdk_version: jdkVersion || null,
jdk_build: jdkBuild || null,
},
})
}
}

View File

@@ -1,14 +0,0 @@
import type { JWTAuth } from '@modrinth/utils'
import { useServersFetch } from '../servers-fetch.ts'
import { ServerModule } from './base.ts'
export class WSModule extends ServerModule implements JWTAuth {
url!: string
token!: string
async fetch(): Promise<void> {
const data = await useServersFetch<JWTAuth>(`servers/${this.serverId}/ws`, {}, 'ws')
Object.assign(this, data)
}
}

View File

@@ -0,0 +1,131 @@
import type { Archon } from '@modrinth/api-client'
import { injectModrinthClient } from '@modrinth/ui'
import { type ComputedRef, ref, watch } from 'vue'
// TODO: Remove and use V1 when available
export function useServerImage(
serverId: string,
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
) {
const client = injectModrinthClient()
const image = ref<string | undefined>()
const sharedImage = useState<string | undefined>(`server-icon-${serverId}`)
if (sharedImage.value) {
image.value = sharedImage.value
}
async function loadImage() {
if (sharedImage.value) {
image.value = sharedImage.value
return
}
if (import.meta.server) return
const cached = localStorage.getItem(`server-icon-${serverId}`)
if (cached) {
sharedImage.value = cached
image.value = cached
return
}
let projectIconUrl: string | undefined
const upstreamVal = upstream.value
if (upstreamVal?.project_id) {
try {
const project = await $fetch<{ icon_url?: string }>(
`https://api.modrinth.com/v2/project/${upstreamVal.project_id}`,
)
projectIconUrl = project.icon_url
} catch {
// project fetch failed, continue without icon url
}
}
try {
const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png')
if (fileData instanceof Blob) {
const dataURL = await resizeImage(fileData, 512)
sharedImage.value = dataURL
localStorage.setItem(`server-icon-${serverId}`, dataURL)
image.value = dataURL
return
}
} catch (error: any) {
if (error?.statusCode >= 500) {
image.value = undefined
return
}
if (error?.statusCode === 404 && projectIconUrl) {
try {
const response = await fetch(projectIconUrl)
if (!response.ok) throw new Error('Failed to fetch icon')
const file = await response.blob()
const originalFile = new File([file], 'server-icon-original.png', {
type: 'image/png',
})
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = 64
canvas.height = 64
ctx?.drawImage(img, 0, 0, 64, 64)
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], 'server-icon.png', {
type: 'image/png',
})
client.kyros.files_v0
.uploadFile('/server-icon.png', scaledFile)
.promise.catch(() => {})
client.kyros.files_v0
.uploadFile('/server-icon-original.png', originalFile)
.promise.catch(() => {})
}
}, 'image/png')
const result = canvas.toDataURL('image/png')
sharedImage.value = result
localStorage.setItem(`server-icon-${serverId}`, result)
resolve(result)
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(file)
})
image.value = dataURL
return
} catch (externalError: any) {
console.debug('Could not process external icon:', externalError.message)
}
}
}
image.value = undefined
}
watch(upstream, () => loadImage(), { immediate: true })
return image
}
function resizeImage(blob: Blob, size: number): Promise<string> {
return new Promise<string>((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = size
canvas.height = size
ctx?.drawImage(img, 0, 0, size, size)
const dataURL = canvas.toDataURL('image/png')
resolve(dataURL)
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(blob)
})
}

View File

@@ -0,0 +1,17 @@
import type { Archon } from '@modrinth/api-client'
import type { Project } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { $fetch } from 'ofetch'
import { computed, type ComputedRef } from 'vue'
// TODO: Remove and use v1
export function useServerProject(
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
) {
return useQuery({
queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]),
queryFn: () =>
$fetch<Project>(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`),
enabled: computed(() => !!upstream.value?.project_id),
})
}

View File

@@ -1304,6 +1304,39 @@
"hosting-marketing.why.your-favorite-mods.description": {
"message": "Choose between Vanilla, Fabric, Forge, Quilt and NeoForge. If it's on Modrinth, it can run on your server."
},
"hosting.loader.failed-to-change-version": {
"message": "Failed to change modpack version"
},
"hosting.loader.failed-to-load-versions": {
"message": "Failed to load versions"
},
"hosting.loader.failed-to-reinstall": {
"message": "Failed to reinstall modpack"
},
"hosting.loader.failed-to-repair": {
"message": "Failed to repair server"
},
"hosting.loader.failed-to-save-settings": {
"message": "Failed to save installation settings"
},
"hosting.loader.failed-to-unlink": {
"message": "Failed to unlink modpack"
},
"hosting.loader.loader-version": {
"message": "{loader, select, null {Loader} other {{loader}}} version"
},
"hosting.loader.repair-started-text": {
"message": "Your server installation has been repaired."
},
"hosting.loader.repair-started-title": {
"message": "Repair completed"
},
"hosting.loader.reset-server": {
"message": "Reset server"
},
"hosting.loader.reset-server-description": {
"message": "Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored."
},
"hosting.plan.out-of-stock": {
"message": "Out of stock"
},
@@ -2783,9 +2816,18 @@
"search.filter.locked.server.sync": {
"message": "Sync with server"
},
"servers.backup.restore.in-progress.tooltip": {
"servers.busy.backup-creating": {
"message": "Backup creation in progress"
},
"servers.busy.backup-restoring": {
"message": "Backup restore in progress"
},
"servers.busy.installing": {
"message": "Server is installing"
},
"servers.busy.syncing-content": {
"message": "Content sync in progress"
},
"servers.notice.actions": {
"message": "Actions"
},

View File

@@ -5,7 +5,6 @@
title="Are you sure you want to remove this project from the organization?"
description="If you proceed, this project will no longer be managed by the organization."
proceed-label="Remove"
:noblur="!(cosmetics?.advancedRendering ?? true)"
@proceed="onRemoveFromOrg"
/>
<Card>
@@ -568,7 +567,6 @@ const {
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const cosmetics = useCosmetics()
const auth = await useAuth()
const allTeamMembers = ref([])

View File

@@ -350,24 +350,16 @@
</template>
</ProjectCard>
</ProjectCardList>
<div v-else>
<div class="mx-auto flex flex-col justify-center gap-8 p-6 text-center">
<EmptyIllustration class="h-[120px] w-auto" />
<div class="-mt-4 flex flex-col gap-4">
<div class="flex flex-col items-center gap-1.5">
<span class="text-lg text-contrast md:text-2xl">{{
formatMessage(messages.noProjectsLabel)
}}</span>
</div>
<EmptyState v-else type="empty-inbox" :heading="formatMessage(messages.noProjectsLabel)">
<template #actions>
<ButtonStyled v-if="auth.user && auth.user.id === creator.id" color="brand">
<nuxt-link class="mx-auto w-min" to="/discover/mods">
<CompassIcon class="size-5" />
Discover mods
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
</template>
</EmptyState>
</NormalPage>
</template>
@@ -377,7 +369,6 @@ import {
ChevronLeftIcon,
CompassIcon,
EditIcon,
EmptyIllustration,
GlobeIcon,
HeartMinusIcon,
LinkIcon,
@@ -398,6 +389,7 @@ import {
ConfirmModal,
defineMessage,
defineMessages,
EmptyState,
FileInput,
HorizontalRule,
injectModrinthClient,

View File

@@ -72,14 +72,11 @@
</div>
</div>
</div>
<div v-else class="mx-auto flex flex-col justify-center p-6 text-center">
<span class="text-lg text-contrast md:text-xl">{{
formatMessage(messages.noTransactions)
}}</span>
<span class="max-w-[256px] text-base text-secondary md:text-lg">{{
formatMessage(messages.noTransactionsDesc)
}}</span>
</div>
<EmptyState
v-else
:heading="formatMessage(messages.noTransactions)"
:description="formatMessage(messages.noTransactionsDesc)"
/>
</div>
</template>
<script setup>
@@ -94,6 +91,7 @@ import {
ButtonStyled,
Combobox,
defineMessages,
EmptyState,
useFormatDateTime,
useFormatMoney,
useVIntl,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import type { Archon, Labrinth } from '@modrinth/api-client'
import {
BookmarkIcon,
CheckIcon,
@@ -12,6 +12,7 @@ import {
InfoIcon,
LeftArrowIcon,
ListIcon,
MinecraftServerIcon,
MoreVerticalIcon,
SearchIcon,
XIcon,
@@ -20,6 +21,8 @@ import {
Avatar,
ButtonStyled,
Checkbox,
type CreationFlowContextValue,
CreationFlowModal,
defineMessages,
DropdownSelect,
injectModrinthClient,
@@ -31,30 +34,31 @@ import {
SearchSidebarFilter,
type SortType,
StyledInput,
Toggle,
useDebugLogger,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { capitalizeString, cycleValue } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useThrottleFn, useTimeoutFn } from '@vueuse/core'
import { computed, type Reactive, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import { projectQueryOptions } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('Discover')
const client = injectModrinthClient()
const queryClient = useQueryClient()
const filtersMenuOpen = ref(false)
const route = useNativeRoute()
const router = useNativeRouter()
const route = useRoute()
const router = useRouter()
const cosmetics = useCosmetics()
const tags = useGeneratedState()
@@ -62,8 +66,6 @@ const flags = useFeatureFlags()
const auth = await useAuth()
const { handleError } = injectNotificationManager()
const modrinthClient = injectModrinthClient()
const queryClient = useQueryClient()
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
const HOVER_DURATION_TO_PREFETCH_MS = 500
@@ -71,14 +73,20 @@ const HOVER_DURATION_TO_PREFETCH_MS = 500
const handleProjectMouseEnter = (result: Labrinth.Search.v2.ResultSearchProject) => {
const slug = result.slug || result.project_id
prefetchTimeout = useTimeoutFn(
() => queryClient.prefetchQuery(projectQueryOptions.v2(slug, modrinthClient)),
() => {
queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, client))
},
HOVER_DURATION_TO_PREFETCH_MS,
{ immediate: false },
)
prefetchTimeout.start()
}
const handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const _handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const slug = result.slug || result.project_id
prefetchTimeout = useTimeoutFn(
@@ -105,10 +113,6 @@ const currentType = computed(() =>
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
)
watch(currentType, (newType) => {
console.log('currentType changed:', newType)
})
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
const projectTypes = computed(() => (projectType.value ? [projectType.value.id] : []))
@@ -121,58 +125,104 @@ const resultsDisplayMode = computed<DisplayMode>(() =>
: 'list',
)
const server = ref<Reactive<ModrinthServer>>()
const serverHideInstalled = ref(false)
const eraseDataOnInstall = ref(false)
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || undefined)
debug('currentServerId:', currentServerId.value)
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
async function updateServerContext() {
const serverId = queryAsString(route.query.sid)
if (!serverId) {
server.value = undefined
return
}
try {
if (!auth.value.user) {
router.push('/auth/sign-in?redirect=' + encodeURIComponent(route.fullPath))
return
}
if (!server.value || server.value.serverId !== serverId) {
server.value = await useModrinthServers(serverId, ['general', 'content'])
}
if (route.query.shi && projectType.value?.id !== 'modpack' && server.value) {
serverHideInstalled.value = route.query.shi === 'true'
}
} catch (error) {
console.error('Failed to load server context:', error)
server.value = undefined
}
}
if (import.meta.client && route.query.sid) {
updateServerContext().catch((error) => {
console.error('Failed to initialize server context:', error)
})
}
watch(
() => route.query.sid,
() => {
updateServerContext().catch((error) => {
console.error('Failed to update server context:', error)
})
const {
data: serverData,
isLoading: serverDataLoading,
error: serverDataError,
} = useQuery({
queryKey: computed(() => ['servers', 'detail', currentServerId.value] as const),
queryFn: () => {
debug('serverData queryFn firing for:', currentServerId.value)
return client.archon.servers_v0.get(currentServerId.value!)
},
enabled: computed(() => {
const enabled = !!currentServerId.value
debug('serverData enabled:', enabled)
return enabled
}),
})
watch(serverData, (val) =>
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
)
watch(serverDataLoading, (val) => debug('serverData loading:', val))
watch(serverDataError, (val) => {
if (val) debug('serverData error:', val)
})
const serverIcon = computed(() => {
if (!currentServerId.value || !import.meta.client) return null
return localStorage.getItem(`server-icon-${currentServerId.value}`)
})
const serverHideInstalled = ref(false)
// TanStack Query for server content list
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
const { data: serverContentData, error: serverContentError } = useQuery({
queryKey: contentQueryKey,
queryFn: () => client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
})
// Watch for errors and notify user
watch(serverContentError, (error) => {
if (error) {
console.error('Failed to load server content:', error)
handleError(error)
}
})
// Re-run search when server content loads so "Hide installed" filter applies
watch(serverContentData, () => {
if (serverHideInstalled.value) {
updateSearchResults(1, false)
}
})
// Install content mutation
const installContentMutation = useMutation({
mutationFn: ({
serverId,
projectId,
versionId,
}: {
serverId: string
projectId: string
versionId: string
}) =>
client.archon.content_v1.addAddon(serverId, currentWorldId.value!, {
project_id: projectId,
version_id: versionId,
}),
onSuccess: () => {
if (currentServerId.value) {
queryClient.refetchQueries({ queryKey: ['content', 'list', currentServerId.value] })
}
},
})
const PERSISTENT_QUERY_PARAMS = ['sid', 'wid', 'shi', 'from']
if (route.query.shi && projectType.value?.id !== 'modpack') {
serverHideInstalled.value = route.query.shi === 'true'
}
const serverFilters = computed(() => {
debug(
'serverFilters recomputing, serverData:',
!!serverData.value,
'projectType:',
projectType.value?.id,
)
const filters = []
if (server.value && projectType.value?.id !== 'modpack') {
const gameVersion = server.value.general?.mc_version
if (serverData.value && projectType.value?.id !== 'modpack') {
const gameVersion = serverData.value.mc_version
if (gameVersion) {
filters.push({
type: 'game_version',
@@ -180,7 +230,7 @@ const serverFilters = computed(() => {
})
}
const platform = server.value.general?.loader?.toLowerCase()
const platform = serverData.value.loader?.toLowerCase()
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
@@ -200,13 +250,20 @@ const serverFilters = computed(() => {
})
}
if (serverHideInstalled.value) {
const installedMods = server.value.content?.data
.filter((x: InstallableMod) => x.project_id)
.map((x: InstallableMod) => x.project_id)
.filter((id): id is string => id !== undefined)
if (projectType.value?.id === 'mod') {
filters.push({
type: 'environment',
option: 'server',
})
}
installedMods
if (serverHideInstalled.value && serverContentData.value) {
const installedIds = (serverContentData.value.addons ?? [])
.filter((x) => x.project_id)
.map((x) => x.project_id)
.filter((id): id is string => id !== null)
installedIds
.map((x: string) => ({
type: 'project_id',
option: `project_id:${x}`,
@@ -215,6 +272,20 @@ const serverFilters = computed(() => {
.forEach((x) => filters.push(x))
}
}
if (currentServerId.value && projectType.value?.id === 'modpack') {
filters.push(
{
type: 'environment',
option: 'client',
},
{
type: 'environment',
option: 'server',
},
)
}
debug('serverFilters result:', filters)
return filters
})
@@ -256,6 +327,7 @@ const {
// Functions
createPageParams,
} = useSearch(projectTypes, tags, serverFilters)
debug('useSearch initialized, requestParams:', requestParams.value)
const selectedFilterTags = computed(() =>
currentFilters.value
@@ -315,42 +387,67 @@ interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject
}
async function serverInstall(project: InstallableSearchResult) {
if (!server.value) {
if (!serverData.value || !currentServerId.value) {
handleError(new Error('No server to install to.'))
return
}
project.installing = true
try {
const versions = (await useBaseFetch(
`project/${project.project_id}/version`,
{},
true,
)) as Labrinth.Versions.v2.Version[]
const version =
versions.find(
(x) =>
x.game_versions.includes(server.value!.general.mc_version) &&
x.loaders.includes(server.value!.general.loader.toLowerCase()),
) ?? versions[0]
if (projectType.value?.id === 'modpack') {
await server.value.general.reinstall(
false,
project.project_id,
version.id,
undefined,
eraseDataOnInstall.value,
// TODO: restore limit=1 once the backend fix for version ordering is deployed (limit is applied before sorting)
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const versionId = versions[0]?.id ?? project.latest_version
if (!versionId) {
handleError(new Error('No version found for this modpack'))
project.installing = false
return
}
const modalInstance = onboardingModalRef.value
if (modalInstance) {
onboardingInstallingProject.value = project
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: project.project_id,
versionId,
name: project.title,
iconUrl: project.icon_url ?? undefined,
}
ctx.modal.value?.setStage('final-config')
}
return
} else if (
projectType.value?.id === 'mod' ||
projectType.value?.id === 'plugin' ||
projectType.value?.id === 'datapack'
) {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id)
const isDatapack = projectType.value?.id === 'datapack'
const version = versions.find((x) => {
if (!x.game_versions.includes(serverData.value!.mc_version!)) return false
if (isDatapack) return true
return x.loaders.includes(serverData.value!.loader!.toLowerCase())
})
if (!version) {
handleError(
new Error(
isDatapack
? `No compatible version found for ${serverData.value!.mc_version}`
: `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`,
),
)
project.installed = true
navigateTo(`/hosting/manage/${server.value.serverId}/options/loader`)
} else if (projectType.value?.id === 'mod') {
await server.value.content.install('mod', version.project_id, version.id)
await server.value.refresh(['content'])
project.installed = true
} else if (projectType.value?.id === 'plugin') {
await server.value.content.install('plugin', version.project_id, version.id)
await server.value.refresh(['content'])
project.installing = false
return
}
await installContentMutation.mutateAsync({
serverId: currentServerId.value,
projectId: version.project_id,
versionId: version.id,
})
project.installed = true
}
} catch (e) {
@@ -361,28 +458,6 @@ async function serverInstall(project: InstallableSearchResult) {
}
const noLoad = ref(false)
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage })
const effectiveSortType = computed({
get: () => (currentType.value === 'server' ? serverCurrentSortType.value : currentSortType.value),
set: (v: SortType) => {
if (currentType.value === 'server') serverCurrentSortType.value = v
else currentSortType.value = v
},
})
const effectiveSortTypes = computed(() =>
currentType.value === 'server' ? serverSortTypes : [...sortTypes],
)
const {
data: rawResults,
refresh: refreshSearch,
@@ -390,35 +465,26 @@ const {
} = useLazyFetch(
() => {
const config = useRuntimeConfig()
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (currentType.value === 'server') {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
return `${base}search${serverRequestParams.value}`
}
return `${base}search${requestParams.value}`
const url = `${base}search${requestParams.value}`
debug('useLazyFetch URL:', url)
return url
},
{
watch: false,
transform: (
hits: Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults,
): Labrinth.Search.v2.SearchResults => {
transform: (hits) => {
debug('useLazyFetch transform, hits:', (hits as any)?.total_hits)
noLoad.value = false
if ('hits_per_page' in hits) {
return {
hits: hits.hits as unknown as Labrinth.Search.v2.ResultSearchProject[],
total_hits: hits.total_hits,
limit: hits.hits_per_page,
offset: (hits.page - 1) * hits.hits_per_page,
}
}
return hits
return hits as Labrinth.Search.v2.SearchResults
},
},
)
const results = shallowRef(toRaw(rawResults))
watch(searchLoading, (val) => debug('searchLoading:', val))
watch(rawResults, (val) => debug('rawResults changed, total_hits:', val?.total_hits))
const results = computed(() => rawResults.value)
const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
@@ -428,6 +494,14 @@ function scrollToTop(behavior: ScrollBehavior = 'smooth') {
}
function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
debug(
'updateSearchResults called, page:',
pageNumber,
'query:',
query.value,
'requestParams:',
requestParams.value,
)
currentPage.value = pageNumber
if (resetScroll) {
scrollToTop()
@@ -435,9 +509,11 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
noLoad.value = true
if (query.value === null) {
debug('updateSearchResults: query is null, returning early')
return
}
debug('updateSearchResults: calling refreshSearch')
refreshSearch()
if (import.meta.client) {
@@ -457,7 +533,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
const params = {
...persistentParams,
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
...createPageParams(),
}
router.replace({ path: route.path, query: params })
@@ -468,12 +544,6 @@ watch([currentFilters], () => {
updateSearchResults(1, false)
})
watch([serverCurrentFilters, serverCurrentSortType], () => {
if (currentType.value === 'server') {
updateSearchResults(1, false)
}
})
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
function cycleSearchDisplayMode() {
@@ -507,79 +577,116 @@ const description = computed(
`Search and browse thousands of Minecraft ${projectType.value?.display ?? 'project'}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${projectType.value?.display ?? 'project'}s.`,
)
const serverBackUrl = computed(() => {
if (!serverData.value) return ''
const id = serverData.value.server_id
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
if (fromContext.value === 'reset-server') return `/hosting/manage/${id}/options/loader`
return `/hosting/manage/${id}/content`
})
// Onboarding modpack flow: show creation flow modal overlay on discovery page
const onboardingModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const onboardingInstallingProject = ref<InstallableSearchResult | null>(null)
function onOnboardingHide() {
if (onboardingInstallingProject.value) {
onboardingInstallingProject.value.installing = false
onboardingInstallingProject.value = null
}
}
function onOnboardingBack() {
onboardingModalRef.value?.hide()
}
async function onModpackFlowCreate(config: CreationFlowContextValue) {
if (!currentServerId.value || !config.modpackSelection.value) return
try {
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties: config.buildProperties(),
} satisfies Archon.Content.v1.InstallWorldContent)
if (fromContext.value === 'onboarding') {
await client.archon.servers_v1.endIntro(currentServerId.value)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
} else {
navigateTo(`/hosting/manage/${currentServerId.value}/options/loader`)
}
} catch (e) {
handleError(new Error(`Error installing modpack: ${e}`))
config.loading.value = false
}
}
useSeoMeta({
description,
ogTitle,
ogDescription: description,
})
const serverHits = computed(
() =>
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
)
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
const content = hit.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick:
project_id !== hit.project_id
? () => {
navigateTo(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === hit.project_id,
}
}
return undefined
}
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<Teleport v-if="server" to="#discover-header-prefix">
<Teleport v-if="serverData" to="#discover-header-prefix" defer>
<div
class="mb-4 flex flex-wrap items-center justify-between gap-3 border-0 border-b border-solid border-divider pb-4"
>
<nuxt-link
:to="`/servers/manage/${server.serverId}/content`"
<button
tabindex="-1"
class="flex flex-col gap-4 text-primary"
class="flex cursor-pointer flex-col gap-4 bg-transparent text-primary"
@click="navigateTo(serverBackUrl)"
>
<span class="flex items-center gap-2">
<Avatar
:src="
server.general.is_medal
serverData.is_medal
? 'https://cdn-raw.modrinth.com/medal_icon.webp'
: server.general.image
: (serverIcon ?? MinecraftServerIcon)
"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="bold font-extrabold text-contrast">
{{ server.general.name }}
{{ serverData.name }}
</span>
<span class="flex items-center gap-2 font-semibold text-secondary">
<GameIcon class="h-5 w-5 text-secondary" />
{{ server.general.loader }} {{ server.general.mc_version }}
{{ serverData.loader }} {{ serverData.mc_version }}
</span>
</span>
</span>
</nuxt-link>
</button>
<ButtonStyled>
<nuxt-link :to="`/hosting/manage/${server.serverId}/content`">
<button @click="navigateTo(serverBackUrl)">
<LeftArrowIcon />
Back to server
</nuxt-link>
{{
fromContext === 'onboarding'
? 'Back to setup'
: fromContext === 'reset-server'
? 'Cancel reset'
: 'Back to server'
}}
</button>
</ButtonStyled>
</div>
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">Install content to server</h1>
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{
fromContext === 'reset-server'
? 'Select modpack to install after reset'
: 'Install content to server'
}}
</h1>
</Teleport>
<aside
@@ -588,7 +695,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
}"
aria-label="Filters"
>
<AdPlaceholder v-if="!auth.user && !server" />
<AdPlaceholder v-if="!auth.user && !serverData" />
<div v-if="filtersMenuOpen" class="fixed inset-0 z-40 bg-bg"></div>
<div
class="flex flex-col gap-3"
@@ -615,23 +722,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</ButtonStyled>
</div>
<div
v-if="server && projectType?.id === 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised"
>
<div class="flex flex-row items-center gap-2 px-6 py-4 text-contrast">
<h3 class="m-0 text-lg">Options</h3>
</div>
<div class="flex flex-row items-center justify-between gap-2 px-6">
<label for="erase-data-on-install"> Erase all data on install </label>
<Toggle id="erase-data-on-install" v-model="eraseDataOnInstall" class="flex-none" />
</div>
<div class="px-6 py-4 text-sm">
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the selected modpack.
</div>
</div>
<div
v-if="server && projectType?.id !== 'modpack'"
v-if="serverData && projectType?.id !== 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised p-4"
>
<Checkbox
@@ -641,37 +732,6 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@update:model-value="updateSearchResults()"
/>
</div>
<template v-if="currentType === 'server'">
<SearchSidebarFilter
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="serverCurrentFilters"
v-model:toggled-groups="serverToggledGroups"
:provided-filters="serverFilters"
:filter-type="filterType"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="
![
'server_category_minecraft_server_meta',
'server_category_minecraft_server_community',
'server_game_version',
'server_status',
].includes(filterType.id)
"
>
<template #header>
<h3 class="m-0 text-lg">{{ filterType.formatted_name }}</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
@@ -707,7 +767,6 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</template>
</div>
</aside>
<section class="normal-page__content">
@@ -727,10 +786,10 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="effectiveSortType"
v-model="currentSortType"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="effectiveSortTypes"
:options="[...sortTypes]"
:display-name="(option?: SortType) => option?.display"
@change="updateSearchResults()"
>
@@ -776,14 +835,6 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
/>
</div>
<SearchFilterControl
v-if="currentType === 'server'"
v-model:selected-filters="serverCurrentFilters"
:filters="serverFilterTypes"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="serverFilters"
@@ -791,14 +842,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
:provided-message="messages.providedByServer"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div
v-else-if="
currentType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="no-results"
>
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
</div>
<div v-else class="search-results-container">
@@ -808,37 +852,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
<template v-if="currentType === 'server'">
<template v-for="result in results?.hits" :key="result.project_id">
<ProjectCard
v-for="project in serverHits"
:key="`server-card-${project.project_id}`"
:title="project.name"
:icon-url="project.icon_url || undefined"
:summary="project.summary"
:tags="project.categories"
:link="`/server/${project.slug}`"
:server-online-players="
project.minecraft_java_server?.ping?.data?.players_online ?? 0
"
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
:server-region="project.minecraft_server?.region"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:server-modpack-content="getServerModpackContent(project)"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
:max-tags="2"
is-server-project
exclude-loaders
@mouseenter="handleServerProjectMouseEnter(project)"
@mouseleave="handleProjectHoverEnd"
>
</ProjectCard>
</template>
<template v-else>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
@@ -858,8 +873,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
clientSide: result.client_side,
serverSide: result.server_side,
}
: undefined
"
@@ -869,7 +884,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons || serverData" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
@@ -893,16 +908,16 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</button>
</ButtonStyled>
</template>
<template v-else-if="server">
<template v-else-if="serverData">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
(serverContentData &&
(serverContentData.addons ?? []).find(
(x) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
serverData.upstream?.project_id === result.project_id
"
disabled
>
@@ -933,6 +948,18 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</div>
</div>
</section>
<CreationFlowModal
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef"
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onOnboardingBack"
@hide="onOnboardingHide"
@browse-modpacks="() => {}"
@create="onModpackFlowCreate"
/>
</template>
<style lang="scss" scoped>
.normal-page__content {

View File

@@ -51,10 +51,7 @@
/>
</div>
<div
v-else-if="
server.moduleErrors?.general?.error.statusCode === 403 ||
server.moduleErrors?.general?.error.statusCode === 404
"
v-else-if="serverError?.statusCode === 403 || serverError?.statusCode === 404"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
@@ -67,7 +64,7 @@
/>
</div>
<div
v-else-if="server.moduleErrors?.general?.error || !nodeAccessible"
v-else-if="serverError || !nodeAccessible"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
@@ -95,39 +92,18 @@
</template>
</ErrorInformationCard>
</div>
<!-- <div
v-else-if="server.moduleErrors?.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Connection lost"
description=""
:icon="TransferIcon"
icon-color="orange"
:action="connectionLostAction"
>
<template #description>
<div class="space-y-4">
<p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue.
</p>
</div>
</template>
</ErrorInformationCard>
</div> -->
<!-- SERVER START -->
<div
v-else-if="serverData"
data-pyro-server-manager-root
class="experimental-styles-within mobile-blurred-servericon relative mx-auto mb-12 box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
:style="{
'--server-bg-image': serverData.image
? `url(${serverData.image})`
'--server-bg-image': serverImage
? `url(${serverImage})`
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
}"
>
<div>
<div class="border-0 border-b border-solid border-divider pb-4">
<NuxtLink to="/hosting/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon />
All servers
@@ -135,7 +111,7 @@
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
<ServerIcon
:image="
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverData.image
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverImage
"
class="drop-shadow-lg sm:drop-shadow-none"
/>
@@ -163,7 +139,9 @@
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
:backup-in-progress="backupInProgress"
:busy-reason="
busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined
"
@action="sendPowerAction"
/>
</div>
@@ -188,26 +166,7 @@
</div>
</div>
<template v-if="serverData.flows?.intro">
<div
v-if="serverData?.status === 'installing'"
class="w-50 h-50 flex items-center justify-center gap-2 text-center text-lg font-bold"
>
<PanelSpinner class="size-10 animate-spin" /> Setting up your server...
</div>
<div v-else>
<h2 class="my-4 text-xl font-extrabold">
What would you like to install on your new server?
</h2>
<ServerInstallation
:server="server as ModrinthServer"
:backup-in-progress="backupInProgress"
ignore-current-installation
@reinstall="onReinstall"
/>
</div>
</template>
<ServerOnboardingPanelPage v-if="serverData.flows?.intro" />
<template v-else>
<div
@@ -309,7 +268,7 @@
</div>
<div v-if="serverData.is_medal" class="mb-4">
<MedalServerCountdown :server-id="server.serverId" />
<MedalServerCountdown :server-id="serverId" />
</div>
<div
@@ -330,21 +289,28 @@
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<ServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<PanelSpinner class="!h-3 !w-3" />
<InstallingTicker />
</div>
</div>
</div>
<InstallingBanner
v-if="
(serverData.status === 'installing' || isSyncingContent) &&
syncProgress?.phase !== 'Analyzing'
"
data-pyro-server-installing
class="mb-4"
:progress="syncProgress"
>
<template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
</template>
</InstallingBanner>
</Transition>
<NuxtPage
:route="route"
:is-connected="isConnected"
@@ -353,9 +319,8 @@
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
@reinstall-failed="onReinstallFailed"
/>
</div>
</template>
@@ -366,7 +331,7 @@
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
safeStringify(server)
safeStringify(serverData)
}}</pre>
</div>
</template>
@@ -374,7 +339,7 @@
<script setup lang="ts">
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
import type { Archon } from '@modrinth/api-client'
import { clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client'
import { clearNodeAuthState, ModrinthApiError, setNodeAuthState } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
@@ -390,37 +355,41 @@ import {
SettingsIcon,
TransferIcon,
} from '@modrinth/assets'
import type { MessageDescriptor } from '@modrinth/ui'
import type { BusyReason } from '@modrinth/ui'
import {
ButtonStyled,
defineMessage,
ErrorInformationCard,
formatLoaderLabel,
injectModrinthClient,
injectNotificationManager,
InstallingBanner,
provideModrinthServerContext,
ServerIcon,
ServerInfoLabels,
ServerNotice,
ServerOnboardingPanelPage,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { PowerAction, Stats } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, type Reactive, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { reloadNuxtApp } from '#app'
import NavTabs from '~/components/ui/NavTabs.vue'
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
import InstallingTicker from '~/components/ui/servers/InstallingTicker.vue'
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
import { useServerImage } from '~/composables/servers/use-server-image.ts'
import { useServerProject } from '~/composables/servers/use-server-project.ts'
import { useModrinthServersConsole } from '~/store/console.ts'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const isReconnecting = ref(false)
@@ -440,26 +409,46 @@ const createdAt = ref(
auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null,
)
const debug = useDebugLogger('ServerManage')
const route = useNativeRoute()
const router = useRouter()
const serverId = route.params.id as string
// TODO: ditch useModrinthServers for this + ctx DI.
const { data: n_server } = useQuery({
const { data: serverData, error: serverQueryError } = useQuery({
queryKey: ['servers', 'detail', serverId],
queryFn: () => client.archon.servers_v0.get(serverId)!,
})
const server: Reactive<ModrinthServer> = await useModrinthServers(serverId, ['general', 'ws'])
function updateServerData(patch: Partial<Archon.Servers.v0.Server>) {
if (!serverData.value) return
queryClient.setQueryData(['servers', 'detail', serverId], {
...serverData.value,
...patch,
})
}
const loadModulesPromise = Promise.resolve().then(() => {
if (server.general?.status === 'suspended') {
return
}
return server.refresh(['content', 'backups', 'network', 'startup'])
const serverError = computed(() => {
const err = serverQueryError.value
if (err instanceof ModrinthApiError) return err
return err ? ModrinthApiError.fromUnknown(err) : null
})
provide('modulesLoaded', loadModulesPromise)
const { data: serverFull } = useQuery({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
})
const worldId = computed(() => {
if (!serverFull.value) return null
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
})
const serverImage = useServerImage(
serverId,
computed(() => serverData.value?.upstream ?? null),
)
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
const errorTitle = ref('Error')
const errorMessage = ref('An unexpected error occurred.')
@@ -483,7 +472,6 @@ function safeStringify(obj: unknown, indent = ' '): string {
)
}
const serverData = computed(() => server.general)
const isConnected = ref(false)
const isWSAuthIncorrect = ref(false)
const modrinthServersConsole = useModrinthServersConsole()
@@ -502,6 +490,70 @@ const markBackupCancelled = (backupId: string) => {
cancelledBackups.add(backupId)
}
// Parthenon state event
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
const syncProgressActive = ref(false)
const isAwaitingPostInstallRefresh = ref(false)
const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn(
() => (syncProgressActive.value = false),
1000,
{ immediate: false },
)
watch(syncProgress, (progress) => {
if (progress != null) {
cancelSyncHide()
syncProgressActive.value = true
} else if (syncProgressActive.value) {
startSyncHide()
}
})
const isSyncingContent = computed(
() => syncProgressActive.value || isAwaitingPostInstallRefresh.value,
)
const busyReasons = computed(() => {
const reasons: BusyReason[] = []
if (serverData.value?.status === 'installing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
defaultMessage: 'Server is installing',
}),
})
}
if (isSyncingContent.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.syncing-content',
defaultMessage: 'Content sync in progress',
}),
})
}
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-creating',
defaultMessage: 'Backup creation in progress',
}),
})
break
}
if (entry.restore?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-restoring',
defaultMessage: 'Backup restore in progress',
}),
})
break
}
}
return reasons
})
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
@@ -520,12 +572,15 @@ setNodeAuthState(() => fsAuth.value, refreshFsAuth)
provideModrinthServerContext({
serverId,
server: n_server as Ref<Archon.Servers.v0.Server>,
worldId,
server: serverData as Ref<Archon.Servers.v0.Server>,
isConnected,
powerState: serverPowerState,
isServerRunning,
backupsState,
markBackupCancelled,
isSyncingContent,
busyReasons,
fsAuth,
fsOps,
fsQueuedOps,
@@ -665,8 +720,8 @@ const popupOptions = computed(
server_id: serverData.value?.server_id,
loader: serverData.value?.loader,
game_version: serverData.value?.mc_version,
modpack_id: serverData.value?.project?.id,
modpack_name: serverData.value?.project?.title,
modpack_id: serverProject.value?.id,
modpack_name: serverProject.value?.title,
},
onOpen: () => console.log(`Opened survey notice: ${surveyNotice.value?.id}`),
onClose: async () => await dismissSurvey(),
@@ -736,6 +791,57 @@ const handlePowerState = (data: Archon.Websocket.v0.WSPowerStateEvent) => {
}
}
const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
debug('[id.vue] handleState received:', {
power_variant: data.power_variant,
progress: data.progress,
serverStatus: serverData.value?.status,
})
syncProgress.value = data.progress
// Sync power state from the state event
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
{
not_ready: 'stopped',
starting: 'starting',
running: 'running',
stopping: 'stopping',
idle:
data.was_oom || (data.exit_code != null && data.exit_code !== 0) ? 'crashed' : 'stopped',
}
updatePowerState(powerMap[data.power_variant], {
exit_code: data.exit_code ?? undefined,
oom_killed: data.was_oom,
})
// Sync uptime
if (data.uptime > 0) {
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
startUptimeUpdates()
}
// Update installing status from progress presence
if (serverData.value) {
if (data.progress != null && serverData.value.status !== 'installing') {
debug('[id.vue] handleState: progress != null, setting status to installing')
hasSeenInstallProgress = true
updateServerData({ status: 'installing' })
} else if (data.progress != null) {
hasSeenInstallProgress = true
} else if (
data.progress == null &&
serverData.value.status === 'installing' &&
hasSeenInstallProgress
) {
debug('[id.vue] handleState: progress null + was installing, applying optimistic update')
hasSeenInstallProgress = false
applyOptimisticCompletion()
invalidateAfterInstall()
}
}
}
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
@@ -847,21 +953,27 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
}
const handleNewMod = () => {
server.refresh(['content'])
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
const newLoader = ref<string | null>(null)
const newLoaderVersion = ref<string | null>(null)
const newMCVersion = ref<string | null>(null)
let hasSeenInstallProgress = false
const onReinstall = async (potentialArgs: any) => {
debug('[id.vue] onReinstall called with:', potentialArgs)
const onReinstall = (potentialArgs: any) => {
if (serverData.value?.flows?.intro) {
server.general?.endIntro()
await client.archon.servers_v1.endIntro(serverId)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}
if (!serverData.value) return
serverData.value.status = 'installing'
debug('[id.vue] onReinstall: setting serverData.status to installing')
hasSeenInstallProgress = false
updateServerData({ status: 'installing' })
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader
@@ -873,52 +985,110 @@ const onReinstall = (potentialArgs: any) => {
newMCVersion.value = potentialArgs.mVersion
}
debug('[id.vue] onReinstall: stored refs:', {
newLoader: newLoader.value,
newLoaderVersion: newLoaderVersion.value,
newMCVersion: newMCVersion.value,
})
error.value = null
errorTitle.value = 'Error'
errorMessage.value = 'An unexpected error occurred.'
// Immediately refetch so loader.vue has fresh data (buttons stay locked via isSyncingContent)
debug('[id.vue] onReinstall: triggering immediate invalidation for loader.vue')
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
switch (data.result) {
case 'ok': {
if (!serverData.value) break
const onReinstallFailed = () => {
debug('[id.vue] onReinstallFailed: reverting status to available')
updateServerData({ status: 'available' })
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
}
try {
await new Promise((resolve) => setTimeout(resolve, 2000))
function applyOptimisticCompletion() {
const patch: Partial<Archon.Servers.v0.Server> = { status: 'available' }
if (newLoader.value) patch.loader = formatLoaderLabel(newLoader.value) as Archon.Servers.v0.Loader
if (newLoaderVersion.value) patch.loader_version = newLoaderVersion.value
if (newMCVersion.value) patch.mc_version = newMCVersion.value
let attempts = 0
const maxAttempts = 3
let hasValidData = false
debug('[id.vue] applyOptimisticCompletion: patch:', patch)
updateServerData(patch)
while (!hasValidData && attempts < maxAttempts) {
attempts++
await server.refresh(['general'], {
preserveConnection: true,
preserveInstallState: true,
const addonsQueries = queryClient.getQueriesData<Archon.Content.v1.Addons>({
queryKey: ['content', 'list', 'v1', serverId],
})
if (serverData.value?.loader && serverData.value?.mc_version) {
hasValidData = true
serverData.value.status = 'available'
await server.refresh(['content', 'startup'])
break
debug(
'[id.vue] applyOptimisticCompletion: found',
addonsQueries.length,
'addons queries to patch',
)
for (const [key, data] of addonsQueries) {
if (!data) continue
const addonsPatch: Record<string, string> = {}
if (newLoader.value) addonsPatch.modloader = newLoader.value
if (newLoaderVersion.value) addonsPatch.modloader_version = newLoaderVersion.value
if (newMCVersion.value) addonsPatch.game_version = newMCVersion.value
if (Object.keys(addonsPatch).length > 0) {
debug('[id.vue] applyOptimisticCompletion: patching addons cache:', addonsPatch)
queryClient.setQueryData(key, { ...data, ...addonsPatch })
}
await new Promise((resolve) => setTimeout(resolve, 2000))
}
if (!hasValidData) {
console.error('Failed to get valid server data after installation')
}
} catch (err: unknown) {
console.error('Error refreshing data after installation:', err)
}
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
}
async function invalidateAfterInstall() {
debug(
'[id.vue] invalidateAfterInstall: setting isAwaitingPostInstallRefresh=true, scheduling 2s delayed invalidation',
)
isAwaitingPostInstallRefresh.value = true
setTimeout(async () => {
debug('[id.vue] invalidateAfterInstall: delayed invalidation firing now')
try {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'startup', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
])
debug('[id.vue] invalidateAfterInstall: delayed invalidation complete')
} catch (err: unknown) {
console.error('Error refreshing data after installation:', err)
} finally {
debug('[id.vue] invalidateAfterInstall: setting isAwaitingPostInstallRefresh=false')
isAwaitingPostInstallRefresh.value = false
}
}, 2000)
}
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
debug('[id.vue] handleInstallationResult received:', data)
switch (data.result) {
case 'ok': {
debug('[id.vue] handleInstallationResult: ok received')
if (!serverData.value) break
debug('[id.vue] handleInstallationResult: stored refs:', {
newLoader: newLoader.value,
newLoaderVersion: newLoaderVersion.value,
newMCVersion: newMCVersion.value,
})
debug('[id.vue] handleInstallationResult: current serverData:', {
status: serverData.value.status,
loader: serverData.value.loader,
loader_version: serverData.value.loader_version,
mc_version: serverData.value.mc_version,
})
applyOptimisticCompletion()
error.value = null
invalidateAfterInstall()
break
}
case 'err': {
@@ -1010,7 +1180,7 @@ const sendPowerAction = async (action: PowerAction) => {
const actionName = action.charAt(0).toUpperCase() + action.slice(1)
try {
isActioning.value = true
await server.general?.power(action)
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error ${toAdverb(actionName)} server:`, error)
notifyError(
@@ -1030,46 +1200,24 @@ const notifyError = (title: string, text: string) => {
})
}
export type BackupInProgressReason = {
type: string
tooltip: MessageDescriptor
}
const restoreInProgressReason = {
type: 'restore',
tooltip: defineMessage({
id: 'servers.backup.restore.in-progress.tooltip',
defaultMessage: 'Backup restore in progress',
}),
} satisfies BackupInProgressReason
const backupInProgress = computed(() => {
for (const entry of backupsState.values()) {
if (entry.restore?.state === 'ongoing') {
return restoreInProgressReason
}
}
return undefined
})
const nodeUnavailableDetails = computed(() => [
{
label: 'Server ID',
value: server.serverId,
value: serverId,
type: 'inline' as const,
},
{
label: 'Node',
value:
(server.moduleErrors?.general?.error.responseData as any)?.hostname ??
server.general?.datacenter ??
(serverError.value?.responseData as any)?.hostname ??
serverData.value?.datacenter ??
'Unknown',
type: 'inline' as const,
},
{
label: 'Error message',
value: nodeAccessible.value
? (server.moduleErrors?.general?.error.message ?? 'Unknown')
? (serverError.value?.message ?? 'Unknown')
: 'Unable to reach node. Ping test failed.',
type: 'block' as const,
},
@@ -1088,38 +1236,38 @@ const suspendedDescription = computed(() => {
const generalErrorDetails = computed(() => [
{
label: 'Server ID',
value: server.serverId,
value: serverId,
type: 'inline' as const,
},
{
label: 'Timestamp',
value: String(server.moduleErrors?.general?.timestamp),
value: String(new Date().toISOString()),
type: 'inline' as const,
},
{
label: 'Error Name',
value: server.moduleErrors?.general?.error.name,
value: serverError.value?.name,
type: 'inline' as const,
},
{
label: 'Error Message',
value: server.moduleErrors?.general?.error.message,
value: serverError.value?.message,
type: 'block' as const,
},
...(server.moduleErrors?.general?.error.originalError
...(serverError.value?.originalError
? [
{
label: 'Original Error',
value: String(server.moduleErrors.general.error.originalError),
value: String(serverError.value.originalError),
type: 'hidden' as const,
},
]
: []),
...(server.moduleErrors?.general?.error.stack
...(serverError.value?.stack
? [
{
label: 'Stack Trace',
value: server.moduleErrors.general.error.stack,
value: serverError.value.stack,
type: 'hidden' as const,
},
]
@@ -1186,35 +1334,70 @@ const cleanup = () => {
}
async function dismissNotice(noticeId: number) {
await useServersFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
method: 'POST',
}).catch((err) => {
await client.archon.servers_v0.dismissNotice(serverId, noticeId).catch((err) => {
addNotification({
title: 'Error dismissing notice',
text: err,
type: 'error',
})
})
await server.refresh(['general'])
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}
const nodeAccessible = ref(true)
onMounted(() => {
isMounted.value = true
if (server.general?.status === 'suspended') {
async function testNodeReachability(): Promise<boolean> {
const nodeInstance = serverData.value?.node?.instance
if (!nodeInstance) {
console.warn('No node instance available for ping test')
return false
}
const wsUrl = `wss://${nodeInstance}/pingtest`
try {
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
socket.close()
resolve(false)
}, 5000)
socket.onopen = () => {
clearTimeout(timeout)
socket.send(performance.now().toString())
}
socket.onmessage = () => {
clearTimeout(timeout)
socket.close()
resolve(true)
}
socket.onerror = () => {
clearTimeout(timeout)
resolve(false)
}
})
} catch (error) {
console.error(`Failed to ping node ${wsUrl}:`, error)
return false
}
}
function initializeServer() {
if (serverData.value?.status === 'suspended') {
isLoading.value = false
return
}
// Skip node test if node is null (upgrading/provisioning)
if (server.general?.node === null) {
if (serverData.value?.node === null) {
isLoading.value = false
return
}
server
.testNodeReachability()
testNodeReachability()
.then((result) => {
nodeAccessible.value = result
if (!nodeAccessible.value) {
@@ -1227,7 +1410,7 @@ onMounted(() => {
isLoading.value = false
})
if (server.moduleErrors.general?.error) {
if (serverError.value) {
isLoading.value = false
} else {
client.archon.sockets
@@ -1244,6 +1427,7 @@ onMounted(() => {
unsubscribers.value = [
client.archon.sockets.on(serverId, 'log', handleLog),
client.archon.sockets.on(serverId, 'stats', handleStats),
client.archon.sockets.on(serverId, 'state', handleState),
client.archon.sockets.on(serverId, 'power-state', handlePowerState),
client.archon.sockets.on(serverId, 'uptime', handleUptime),
client.archon.sockets.on(serverId, 'auth-incorrect', handleAuthIncorrect),
@@ -1255,14 +1439,33 @@ onMounted(() => {
]
})
.catch((error) => {
console.error('Failed to connect WebSocket:', error)
debug('[id.vue] Failed to connect WebSocket:', error)
isConnected.value = false
isLoading.value = false
})
}
if (server.general?.flows?.intro && server.general?.project) {
server.general?.endIntro()
if (serverData.value?.flows?.intro && serverProject.value) {
client.archon.servers_v1.endIntro(serverId).then(() => {
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
})
}
}
onMounted(() => {
isMounted.value = true
// serverData comes from useQuery and may not be available yet at mount time.
// Wait for it before initializing WebSocket, node reachability, etc.
if (serverData.value) {
initializeServer()
} else {
const stopWatch = watch(serverData, (data) => {
if (data) {
stopWatch()
initializeServer()
}
})
}
if (username.value && email.value && userId.value && createdAt.value) {

View File

@@ -1,21 +1,14 @@
<template>
<div class="flex h-full w-full flex-col">
<NuxtPage :route="route" :server="props.server" />
</div>
</template>
<script setup lang="ts">
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { injectModrinthServerContext, ServersManageContentPage } from '@modrinth/ui'
const route = useNativeRoute()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
const { server } = injectModrinthServerContext()
const flags = useFeatureFlags()
useHead({
title: `Content - ${data.value?.name ?? 'Server'} - Modrinth`,
title: `Content - ${server.value?.name ?? 'Server'} - Modrinth`,
})
</script>
<template>
<ServersManageContentPage :show-client-only-filter="flags.developerMode" />
</template>

View File

@@ -1,704 +0,0 @@
<template>
<ContentVersionEditModal
v-if="!invalidModal"
ref="versionEditModal"
:type="type"
:mod-pack="Boolean(props.server.general?.upstream)"
:game-version="props.server.general?.mc_version ?? ''"
:loader="props.server.general?.loader?.toLowerCase() ?? ''"
:server-id="props.server.serverId"
@change-version="changeModVersion($event)"
/>
<div
v-if="server.moduleErrors.content"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.content.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col">
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
<div class="flex w-full flex-col-reverse items-center gap-2 sm:flex-row">
<div class="flex w-full items-center gap-2">
<div class="flex-1 text-sm">
<label class="sr-only" for="search">Search</label>
<StyledInput
id="search"
v-model="searchInput"
wrapper-class="w-full"
type="search"
:icon="SearchIcon"
name="search"
autocomplete="off"
:placeholder="`Search ${localMods.length} ${type.toLocaleLowerCase()}s...`"
@input="debouncedSearch"
/>
</div>
<ButtonStyled>
<TeleportOverflowMenu
position="bottom"
direction="left"
:aria-label="`Filter ${type}s`"
:options="[
{ id: 'all', action: () => (filterMethod = 'all') },
{ id: 'enabled', action: () => (filterMethod = 'enabled') },
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
]"
>
<span class="hidden whitespace-pre sm:block">
{{ filterMethodLabel }}
</span>
<FilterIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #all> All {{ type.toLocaleLowerCase() }}s </template>
<template #enabled> Only enabled </template>
<template #disabled> Only disabled </template>
</TeleportOverflowMenu></ButtonStyled
>
</div>
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
<ButtonStyled>
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
<FileIcon />
Add file
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/discover/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<FilesUploadDropdown
ref="uploadDropdownRef"
class="rounded-xl bg-bg-raised"
:margin-bottom="16"
:file-type="type"
:current-path="`/${type.toLocaleLowerCase()}s`"
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
@upload-complete="() => props.server.refresh(['content'])"
/>
<FilesUploadDragAndDrop
v-if="server.general && localMods"
class="relative min-h-[50vh]"
overlay-class="rounded-xl border-2 border-dashed border-secondary"
:type="type"
@files-dropped="handleDroppedFiles"
>
<div v-if="hasFilteredMods" class="flex flex-col gap-2 transition-all">
<div ref="listContainer" class="relative w-full">
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
<div
:style="{
position: 'absolute',
top: `${visibleTop}px`,
width: '100%',
}"
>
<template v-for="mod in visibleItems.items" :key="getStableModKey(mod)">
<div
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
style="height: 64px"
>
<NuxtLink
:to="
mod.project_id
? `/project/${mod.project_id}/version/${mod.version_id}`
: `files?path=${type.toLocaleLowerCase()}s`
"
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
draggable="false"
>
<Avatar
:src="mod.icon_url"
size="sm"
alt="Server Icon"
:class="mod.disabled ? 'opacity-75 grayscale' : ''"
/>
<div class="flex min-w-0 flex-col gap-1">
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
<span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
<span
v-if="mod.disabled"
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
>Disabled</span
>
</span>
<div class="min-w-0 text-xs text-secondary">
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
<span class="block font-semibold sm:hidden">
{{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
</span>
</div>
</div>
</NuxtLink>
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
<div class="truncate font-semibold text-contrast">
<span v-tooltip="`${type} version`">{{
mod.version_number || `External ${type.toLocaleLowerCase()}`
}}</span>
</div>
<div class="truncate">
<span v-tooltip="`${type} file name`">
{{ mod.filename }}
</span>
</div>
</div>
<div
class="flex items-center justify-end gap-2 pr-4 font-semibold text-contrast sm:min-w-44"
>
<ButtonStyled color="red" type="transparent">
<button
v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
:disabled="mod.changing"
class="!hidden sm:!block"
@click="removeMod(mod)"
>
<TrashIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="
mod.project_id
? `Edit ${type.toLocaleLowerCase()} version`
: `External ${type.toLocaleLowerCase()}s cannot be edited`
"
:disabled="mod.changing || !mod.project_id"
class="!hidden sm:!block"
@click="showVersionModal(mod)"
>
<template v-if="mod.changing">
<LoadingIcon class="animate-spin" />
</template>
<template v-else>
<EditIcon />
</template>
</button>
</ButtonStyled>
<!-- Dropdown for mobile -->
<div class="mr-2 flex items-center sm:hidden">
<LoadingIcon
v-if="mod.changing"
class="mr-2 h-5 w-5 animate-spin"
style="color: var(--color-base)"
/>
<ButtonStyled v-else circular type="transparent">
<TeleportOverflowMenu
:options="[
{
id: 'edit',
action: () => showVersionModal(mod),
shown: !!(mod.project_id && !mod.changing),
},
{
id: 'delete',
action: () => removeMod(mod),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #edit>
<EditIcon class="h-5 w-5" />
<span>Edit</span>
</template>
<template #delete>
<TrashIcon class="h-5 w-5" />
<span>Delete</span>
</template>
</TeleportOverflowMenu></ButtonStyled
>
</div>
<Toggle
:id="`toggle-${getStableModKey(mod)}`"
:model-value="!mod.disabled"
:disabled="mod.changing"
@update:model-value="toggleMod(mod)"
/>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- no mods has platform -->
<div
v-else-if="
props.server.general?.loader &&
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<div
v-if="!hasFilteredMods && hasMods"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<SearchIcon class="size-24" />
<p class="m-0 font-bold text-contrast">
No {{ type.toLocaleLowerCase() }}s found for your query!
</p>
<p class="m-0">Try another query, or show everything.</p>
<ButtonStyled>
<button @click="showAll">
<ListIcon />
Show everything
</button>
</ButtonStyled>
</div>
<div
v-else
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<PackageClosedIcon class="size-24" />
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
<p class="m-0">
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled type="outlined">
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
<FileIcon />
Add file
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/discover/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
<LoaderIcon loader="Vanilla" class="size-24" />
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
<p class="m-0">
Add content to your server by installing a modpack or choosing a different platform that
supports {{ type }}s.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled class="mt-8">
<NuxtLink :to="`/discover/modpacks?sid=${props.server.serverId}`">
<CompassIcon />
Find a modpack
</NuxtLink>
</ButtonStyled>
<div>or</div>
<ButtonStyled class="mt-8">
<NuxtLink :to="`/hosting/manage/${props.server.serverId}/options/loader`">
<WrenchIcon />
Change platform
</NuxtLink>
</ButtonStyled>
</div>
</div>
</FilesUploadDragAndDrop>
</div>
</div>
</template>
<script setup lang="ts">
import {
CompassIcon,
DropdownIcon,
EditIcon,
FileIcon,
FilterIcon,
IssuesIcon,
ListIcon,
MoreVerticalIcon,
PackageClosedIcon,
PlusIcon,
SearchIcon,
TrashIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
injectModrinthClient,
injectNotificationManager,
StyledInput,
Toggle,
} from '@modrinth/ui'
import type { Mod } from '@modrinth/utils'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ContentVersionEditModal from '~/components/ui/servers/ContentVersionEditModal.vue'
import FilesUploadDragAndDrop from '~/components/ui/servers/FilesUploadDragAndDrop.vue'
import FilesUploadDropdown from '~/components/ui/servers/FilesUploadDropdown.vue'
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
import LoadingIcon from '~/components/ui/servers/icons/LoadingIcon.vue'
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const props = defineProps<{
server: ModrinthServer
}>()
const type = computed(() => {
const loader = props.server.general?.loader?.toLowerCase()
return loader === 'paper' || loader === 'purpur' ? 'Plugin' : 'Mod'
})
interface ContentItem extends Mod {
changing?: boolean
}
const ITEM_HEIGHT = 72
const BUFFER_SIZE = 5
const listContainer = ref<HTMLElement | null>(null)
const windowScrollY = ref(0)
const windowHeight = ref(0)
const localMods = ref<ContentItem[]>([])
const searchInput = ref('')
const modSearchInput = ref('')
const filterMethod = ref('all')
const uploadDropdownRef = ref()
const versionEditModal = ref()
const currentEditMod = ref<ContentItem | null>(null)
const invalidModal = computed(
() => !props.server.general?.mc_version || !props.server.general?.loader,
)
async function changeModVersion(event: string) {
const mod = currentEditMod.value
if (mod) mod.changing = true
try {
versionEditModal.value.hide()
// This will be used instead once backend implementation is done
// await props.server.content?.reinstall(
// `/${type.value.toLowerCase()}s/${event.fileName}`,
// currentMod.value.project_id,
// currentVersion.value.id,
// );
await props.server.content?.install(
type.value.toLowerCase() as 'mod' | 'plugin',
mod?.project_id || '',
event,
)
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod?.filename}`)
await props.server.refresh(['general', 'content'])
} catch (error) {
const errmsg = `Error changing mod version: ${error}`
console.error(errmsg)
addNotification({
text: errmsg,
type: 'error',
})
return
}
if (mod) mod.changing = false
}
function showVersionModal(mod: ContentItem) {
if (invalidModal.value || !mod?.project_id || !mod?.filename) {
const errmsg = invalidModal.value
? 'Data required for changing mod version was not found.'
: `${!mod?.project_id ? 'No mod project ID found' : 'No mod filename found'} for ${friendlyModName(mod!)}`
console.error(errmsg)
addNotification({
text: errmsg,
type: 'error',
})
return
}
currentEditMod.value = mod
versionEditModal.value.show(mod)
}
const handleDroppedFiles = (files: File[]) => {
files.forEach((file) => {
uploadDropdownRef.value?.uploadFile(file)
})
}
const initiateFileUpload = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = acceptFileFromProjectType(type.value.toLowerCase())
input.multiple = true
input.onchange = () => {
if (input.files) {
Array.from(input.files).forEach((file) => {
uploadDropdownRef.value?.uploadFile(file)
})
}
}
input.click()
}
const showAll = () => {
searchInput.value = ''
modSearchInput.value = ''
filterMethod.value = 'all'
}
const filterMethodLabel = computed(() => {
switch (filterMethod.value) {
case 'disabled':
return 'Only disabled'
case 'enabled':
return 'Only enabled'
default:
return `All ${type.value.toLocaleLowerCase()}s`
}
})
const totalHeight = computed(() => {
const itemsHeight = filteredMods.value.length * ITEM_HEIGHT
return itemsHeight
})
const getVisibleRange = () => {
if (!listContainer.value) return { start: 0, end: 0 }
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
const scrollTop = Math.max(0, windowScrollY.value - containerTop)
const start = Math.floor(scrollTop / ITEM_HEIGHT)
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(filteredMods.value.length, start + visibleCount + BUFFER_SIZE * 2),
}
}
const visibleTop = computed(() => {
const range = getVisibleRange()
return range.start * ITEM_HEIGHT
})
const visibleItems = computed(() => {
const range = getVisibleRange()
const items = filteredMods.value
return {
items: items.slice(Math.max(0, range.start), Math.min(items.length, range.end)),
}
})
const handleScroll = () => {
windowScrollY.value = window.scrollY
}
const handleResize = () => {
windowHeight.value = window.innerHeight
}
onMounted(() => {
windowHeight.value = window.innerHeight
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
handleScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
watch(
() => props.server.content?.data,
(newMods) => {
if (newMods) {
localMods.value = [...newMods]
}
},
{ immediate: true },
)
const debounce = <T extends (...args: any[]) => void>(
func: T,
wait: number,
): ((...args: Parameters<T>) => void) => {
let timeout: ReturnType<typeof setTimeout>
return function (...args: Parameters<T>): void {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
const pyroContentSentinel = ref<HTMLElement | null>(null)
const debouncedSearch = debounce(() => {
modSearchInput.value = searchInput.value
if (pyroContentSentinel.value) {
const sentinelRect = pyroContentSentinel.value.getBoundingClientRect()
if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) {
pyroContentSentinel.value.scrollIntoView({
// behavior: "smooth",
block: 'start',
})
}
}
}, 300)
function friendlyModName(mod: ContentItem) {
if (mod.name) return mod.name
// remove .disabled if at the end of the filename
let cleanName = mod.filename.endsWith('.disabled') ? mod.filename.slice(0, -9) : mod.filename
// remove everything after the last dot
const lastDotIndex = cleanName.lastIndexOf('.')
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex)
return cleanName
}
function getStableModKey(mod: ContentItem): string {
if (mod.project_id) {
return `project-${mod.project_id}`
}
// external file
const baseFilename = mod.filename.endsWith('.disabled') ? mod.filename.slice(0, -9) : mod.filename
return `file-${baseFilename}`
}
async function toggleMod(mod: ContentItem) {
mod.changing = true
const originalFilename = mod.filename
try {
const newFilename = mod.filename.endsWith('.disabled')
? mod.filename.slice(0, -9)
: `${mod.filename}.disabled`
const folder = `${type.value.toLocaleLowerCase()}s`
const sourcePath = `/${folder}/${mod.filename}`
const destinationPath = `/${folder}/${newFilename}`
mod.disabled = newFilename.endsWith('.disabled')
mod.filename = newFilename
await client.kyros.files_v0.moveFileOrFolder(sourcePath, destinationPath)
await props.server.refresh(['general', 'content'])
} catch (error) {
mod.filename = originalFilename
mod.disabled = originalFilename.endsWith('.disabled')
console.error('Error toggling mod:', error)
addNotification({
text: `Something went wrong toggling ${friendlyModName(mod)}`,
type: 'error',
})
}
mod.changing = false
}
async function removeMod(mod: ContentItem) {
mod.changing = true
try {
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`)
await props.server.refresh(['general', 'content'])
} catch (error) {
console.error('Error removing mod:', error)
addNotification({
text: `couldn't remove ${mod.name || mod.filename}`,
type: 'error',
})
}
mod.changing = false
}
const hasMods = computed(() => {
return localMods.value?.length > 0
})
const hasFilteredMods = computed(() => {
return filteredMods.value?.length > 0
})
const filteredMods = computed(() => {
const mods = modSearchInput.value.trim()
? localMods.value.filter(
(mod) =>
mod.name?.toLowerCase().includes(modSearchInput.value.toLowerCase()) ||
mod.filename.toLowerCase().includes(modSearchInput.value.toLowerCase()),
)
: localMods.value
const statusFilteredMods = (() => {
switch (filterMethod.value) {
case 'disabled':
return mods.filter((mod) => mod.disabled)
case 'enabled':
return mods.filter((mod) => !mod.disabled)
default:
return mods
}
})()
return statusFilteredMods.sort((a, b) => {
return friendlyModName(a).localeCompare(friendlyModName(b))
})
})
</script>
<style scoped>
.sentinel {
position: absolute;
top: -1rem;
left: 0;
right: 0;
height: 1px;
visibility: hidden;
}
</style>

View File

@@ -1,78 +1,58 @@
<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<div
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
Your server is still accessible during this time.
</Admonition>
<Admonition
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
data-pyro-servers-inspecting-error
class="flex justify-between rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
type="critical"
:header="`${serverData?.name} shut down unexpectedly.`"
dismissible
@dismiss="clearError"
>
<div class="flex w-full justify-between gap-2">
<div v-if="inspectingError.analysis.problems.length" class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">
{{ serverData?.name }} shut down unexpectedly. We've automatically analyzed the logs
and found the following problems:
</div>
<li
<template v-if="inspectingError.analysis.problems.length">
<p class="m-0 text-sm opacity-80">
We automatically analyzed the logs and found the following:
</p>
<div class="mt-2 flex flex-col gap-2">
<div
v-for="problem in inspectingError.analysis.problems"
:key="problem.message"
class="list-none"
class="bg-raised-bg/30 rounded-xl px-3 py-2"
>
<h4 class="m-0 text-sm font-normal sm:text-lg sm:font-semibold">
{{ problem.message }}
</h4>
<ul class="m-0 ml-6">
<li v-for="solution in problem.solutions" :key="solution.message">
<span class="m-0 text-sm font-normal">{{ solution.message }}</span>
<p class="m-0 text-sm font-semibold">{{ problem.message }}</p>
<ul v-if="problem.solutions.length" class="m-0 ml-4 mt-1.5 flex flex-col gap-1">
<li
v-for="solution in problem.solutions"
:key="solution.message"
class="text-sm opacity-80"
>
{{ solution.message }}
</li>
</ul>
</li>
</div>
</div>
<div v-else-if="props.serverPowerState === 'crashed'" class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
<div class="font-normal">
</template>
<template v-else-if="props.serverPowerState === 'crashed'">
<template v-if="props.powerStateDetails?.oom_killed">
The server stopped because it ran out of memory. There may be a memory leak caused
by a mod or plugin, or you may need to upgrade your Modrinth Server.
The server stopped because it ran out of memory. There may be a memory leak caused by a
mod or plugin, or you may need to upgrade your Modrinth Server.
</template>
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
We could not automatically determine the specific cause of the crash, but your
server exited with code
{{ props.powerStateDetails.exit_code }}.
{{
props.powerStateDetails.exit_code === 1
? 'There may be a mod or plugin causing the issue, or an issue with your server configuration.'
: ''
}}
Your server exited with code {{ props.powerStateDetails.exit_code }}.
<template v-if="props.powerStateDetails.exit_code === 1">
There may be a mod or plugin causing the issue, or an issue with your server
configuration.
</template>
</template>
<template v-else> We could not determine the specific cause of the crash. </template>
<div class="mt-2">You can try restarting the server.</div>
</div>
</div>
</div>
<div v-else class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
<div class="font-normal">
<p class="m-0 mt-2">You can try restarting the server.</p>
</template>
<template v-else>
We could not find any specific problems, but you can try restarting the server.
</div>
</div>
</div>
<ButtonStyled color="red" @click="clearError">
<button>
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
</Admonition>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<ServerStats
@@ -181,14 +161,18 @@
</template>
<script setup lang="ts">
import { IssuesIcon, TerminalSquareIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui'
import { TerminalSquareIcon } from '@modrinth/assets'
import {
Admonition,
injectModrinthClient,
injectModrinthServerContext,
useVIntl,
} from '@modrinth/ui'
import type { ServerState, Stats } from '@modrinth/utils'
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
import PanelTerminal from '~/components/ui/servers/PanelTerminal.vue'
import ServerStats from '~/components/ui/servers/ServerStats.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
type ServerProps = {
isConnected: boolean
@@ -200,13 +184,22 @@ type ServerProps = {
exit_code?: number
}
isServerRunning: boolean
server: ModrinthServer
}
const props = defineProps<ServerProps>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const serverId = props.server.serverId
const { server: serverData, serverId, busyReasons } = injectModrinthServerContext()
const backupBusyReason = computed(() => {
const reason = busyReasons.value.find(
(r) =>
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring',
)
return reason ? formatMessage(reason.reason) : null
})
interface ErrorData {
id: string
@@ -581,7 +574,6 @@ const commandInput = ref('')
const suggestions = ref<string[]>([])
const selectedSuggestionIndex = ref(0)
const serverData = computed(() => props.server.general)
// const serverIP = computed(() => serverData.value?.net.ip ?? "");
// const serverPort = computed(() => serverData.value?.net.port ?? 0);
// const serverDomain = computed(() => serverData.value?.net.domain ?? "");

Some files were not shown because too many files have changed in this diff Show More