feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 15:38:08 -06:00
committed by GitHub
parent a2a97d1313
commit 693a371d61
278 changed files with 15974 additions and 12608 deletions

View File

@@ -1,5 +1,12 @@
<script setup>
import { AuthFeature, PanelVersionFeature, TauriModrinthClient } from '@modrinth/api-client'
import {
AuthFeature,
NodeAuthFeature,
nodeAuthState,
PanelVersionFeature,
TauriModrinthClient,
VerboseLoggingFeature,
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
@@ -19,7 +26,7 @@ import {
RefreshCwIcon,
RestoreIcon,
RightArrowIcon,
ServerIcon,
ServerStackIcon,
SettingsIcon,
UserIcon,
WorldIcon,
@@ -80,6 +87,7 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
@@ -127,17 +135,29 @@ const { addPopupNotification } = popupNotificationManager
const tauriApiClient = new TauriModrinthClient({
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
labrinthBaseUrl: config.labrinthBaseUrl,
archonBaseUrl: config.archonBaseUrl,
features: [
new NodeAuthFeature({
getAuth: () => nodeAuthState.getAuth?.() ?? null,
refreshAuth: async () => {
if (nodeAuthState.refreshAuth) {
await nodeAuthState.refreshAuth()
}
},
}),
new AuthFeature({
token: async () => (await getCreds()).session,
token: async () => (await getCreds())?.session,
}),
new PanelVersionFeature(),
new VerboseLoggingFeature(),
],
})
provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
openExternalUrl: (url) => openUrl(url),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
@@ -395,17 +415,30 @@ const handleClose = async () => {
}
const router = useRouter()
const route = useRoute()
const loading = useLoading()
loading.setEnabled(false)
loading.startLoading()
let suspensePending = false
router.beforeEach(() => {
suspensePending = false
loading.startLoading()
})
router.afterEach((to, from, failure) => {
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
failed: failure,
})
setTimeout(() => {
if (!suspensePending) {
loading.stopLoading()
}
}, 100)
})
const route = useRoute()
const loading = useLoading()
loading.setEnabled(false)
const error = useError()
const errorModal = ref()
@@ -982,13 +1015,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-if="themeStore.featureFlags.servers_in_app"
v-tooltip.right="'Servers'"
to="/hosting/manage"
>
<ServerIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
to="/browse/modpack"
@@ -1003,6 +1029,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton
v-tooltip.right="'Library'"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
() =>
route.path.startsWith('/instance') ||
@@ -1012,6 +1039,14 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="'Modrinth Hosting'"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
:is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'"
>
<ServerStackIcon />
</NavButton>
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
<suspense>
<QuickInstanceSwitcher />
@@ -1181,7 +1216,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
</transition>
<div
class="loading-indicator-container h-8 fixed z-50"
class="loading-indicator-container h-8 fixed z-50 pointer-events-none"
:style="{
top: 'calc(var(--top-bar-height))',
left: 'calc(var(--left-bar-width))',
@@ -1224,7 +1259,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</Admonition>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
<Suspense
@pending="
() => {
suspensePending = true
loading.startLoading()
}
"
@resolve="loading.stopLoading()"
>
<component :is="Component"></component>
</Suspense>
</template>
@@ -1250,11 +1293,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
<div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense>
<FriendsList
:credentials="credentials"
:sign-in="() => signIn()"
:refresh-credentials="fetchCredentials"
/>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<div v-if="news && news.length > 0" class="p-4 pr-1 flex flex-col items-center">
@@ -1287,8 +1326,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
</div>
<I18nDebugPanel />
<NotificationPanel has-sidebar />
<PopupNotificationPanel has-sidebar />
<NotificationPanel :has-sidebar="sidebarVisible" />
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ContentInstallModal

View File

@@ -45,6 +45,14 @@
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);
--medal-promotion-bg: #000;
--medal-promotion-bg-orange: rgba(208, 246, 255, 0.25);
--medal-promotion-text-orange: #42abff;
--medal-promotion-bg-gradient: linear-gradient(
90deg,
rgba(66, 170, 255, 0.15),
rgba(0, 0, 0, 0) 100%
);
}
body {
@@ -77,12 +85,10 @@ body {
}
a {
color: var(--color-link);
color: inherit;
text-decoration: none;
&:hover {
text-decoration: none;
}
-webkit-font-smoothing: antialiased;
will-change: filter;
}
.badge {
@@ -174,4 +180,63 @@ img {
}
}
button,
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
}
@import '@modrinth/assets/omorphia.scss';
input {
border-radius: var(--size-rounded-sm);
box-sizing: border-box;
border: 2px solid transparent;
// safari iOS rounds inputs by default
// set the appearance to none to prevent this
appearance: none !important;
}
pre {
font-weight: var(--font-weight-regular);
}
input,
textarea {
background: var(--color-button-bg);
color: var(--color-text);
padding: 0.5rem 1rem;
font-weight: var(--font-weight-medium);
border: none;
outline: 2px solid transparent;
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
transition: box-shadow 0.1s ease-in-out;
min-height: 36px;
&:focus,
&:focus-visible {
box-shadow:
inset 0 0 0 transparent,
0 0 0 0.25rem var(--color-brand-shadow);
color: var(--color-button-text-active);
}
&:disabled,
&[disabled='true'] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
&:focus::placeholder {
opacity: 0.8;
}
&::placeholder {
color: var(--color-button-text);
opacity: 0.6;
}
}

View File

@@ -3,6 +3,7 @@
v-if="typeof to === 'string'"
:to="to"
v-bind="$attrs"
:active-class="isSubpage ? '' : undefined"
:class="{
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),

View File

@@ -1,142 +0,0 @@
<template>
<ProjectCard
:title="project.name"
:link="
() => {
emit('open')
$router.push({
path: `/project/${project.project_id ?? project.id}`,
query: { i: props.instance ? props.instance.path : undefined },
})
}
"
:author="{ name: project.author, link: `https://modrinth.com/user/${project.author}` }"
:icon-url="project.icon_url"
:summary="project.summary"
:tags="project.display_categories"
:all-tags="project.categories"
:downloads="project.downloads"
:followers="project.follows"
:date-updated="project.date_modified"
:banner="project.featured_gallery ?? undefined"
:color="project.color ?? undefined"
:environment="
['mod', 'modpack'].includes(projectType)
? {
clientSide: project.client_side?.[0],
serverSide: project.server_side?.[0],
}
: undefined
"
layout="list"
>
<template #actions>
<ButtonStyled color="brand" type="outlined">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@click.stop="install()"
>
<SpinnerIcon v-if="installing" class="animate-spin" />
<template v-else-if="!installed">
<DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else />
</template>
<CheckIcon v-else />
{{
installing
? 'Installing'
: installed
? 'Installed'
: modpack || instance
? 'Install'
: 'Add to an instance'
}}
</button>
</ButtonStyled>
</template>
</ProjectCard>
</template>
<script setup>
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 { injectContentInstall } from '@/providers/content-install'
const { install: installVersion } = injectContentInstall()
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const router = useRouter()
const props = defineProps({
backgroundImage: {
type: String,
default: null,
},
project: {
type: Object,
required: true,
},
instance: {
type: Object,
default: null,
},
featured: {
type: Boolean,
default: false,
},
installed: {
type: Boolean,
default: false,
},
projectType: {
type: String,
default: undefined,
},
activeLoader: {
type: String,
default: null,
},
activeGameVersion: {
type: String,
default: null,
},
})
const emit = defineEmits(['open', 'install'])
const installing = ref(false)
async function install() {
installing.value = true
await installVersion(
props.project.project_id ?? props.project.id,
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)
}
const modpack = computed(() => props.project.project_types?.includes('modpack'))
</script>

View File

@@ -1,11 +1,9 @@
<script setup>
import { Button, injectNotificationManager } from '@modrinth/ui'
import { Button, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { ref } from 'vue'
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 { get_project_v3, get_version } from '@/helpers/cache.js'
import { injectContentInstall } from '@/providers/content-install'
const { handleError } = injectNotificationManager()
@@ -14,26 +12,22 @@ const { install: installVersion } = injectContentInstall()
const confirmModal = ref(null)
const project = ref(null)
const version = ref(null)
const categories = ref(null)
const installing = ref(false)
defineExpose({
async show(event) {
if (event.event === 'InstallVersion') {
version.value = await get_version(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project(version.value.project_id, 'must_revalidate').catch(
project.value = await get_project_v3(version.value.project_id, 'must_revalidate').catch(
handleError,
)
} else {
project.value = await get_project(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project_v3(event.id, 'must_revalidate').catch(handleError)
version.value = await get_version(
project.value.versions[project.value.versions.length - 1],
'must_revalidate',
).catch(handleError)
}
categories.value = (await get_categories().catch(handleError)).filter(
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
)
confirmModal.value.show()
},
})
@@ -52,13 +46,22 @@ async function install() {
</script>
<template>
<ModalWrapper ref="confirmModal" :header="`Install ${project?.title}`">
<ModalWrapper ref="confirmModal" :header="`Install ${project?.name}`">
<div class="modal-body">
<SearchCard
:project="project"
<ProjectCard
:title="project.name"
:link="() => confirmModal.hide()"
:icon-url="project.icon_url"
:summary="project.summary"
:tags="project.display_categories"
:all-tags="project.categories"
:downloads="project.downloads"
:followers="project.follows"
:date-updated="project.date_modified"
:banner="project.featured_gallery ?? undefined"
:color="project.color ?? undefined"
layout="list"
class="project-card"
:categories="categories"
@open="confirmModal.hide()"
/>
<div class="button-row">
<div class="markdown-body">

View File

@@ -198,45 +198,48 @@ const messages = defineMessages({
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeProfile" />
<div class="block">
<div class="float-end ml-4 relative group">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="!border-4 group-hover:brightness-75"
:tint-by="instance.path"
no-shadow
/>
<div class="absolute top-0 right-0 m-2">
<div
class="p-2 m-0 text-primary flex items-center justify-center aspect-square bg-button-bg rounded-full border-button-border border-solid border-[1px] hovering-icon-shadow"
<div class="float-end ml-10 relative group w-fit">
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
>
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
</div>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="transition-[filter] group-hover:brightness-75"
:tint-by="instance.path"
no-shadow
/>
<div
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
>
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</div>
</div>
<label for="instance-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
<label for="instance-name" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.name) }}
</label>
<div class="flex">
@@ -249,76 +252,82 @@ const messages = defineMessages({
/>
</div>
<template v-if="instance.install_stage == 'installed'">
<div>
<h2
id="duplicate-instance-label"
class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"
>
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.duplicateInstance) }}
</h2>
<p class="m-0 mb-2">
<ButtonStyled>
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
class="w-max !shadow-none"
@click="duplicateProfile"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
</ButtonStyled>
<p class="m-0">
{{ formatMessage(messages.duplicateInstanceDescription) }}
</p>
</div>
<ButtonStyled>
</template>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newCategoryInput"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
class="w-full max-w-[300px]"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit !shadow-none" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
</div>
</div>
<p class="m-0">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<ButtonStyled color="red">
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
@click="duplicateProfile"
aria-labelledby="delete-instance-label"
:disabled="removing"
class="w-fit !shadow-none"
@click="deleteConfirmModal.show()"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
</template>
<h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newCategoryInput"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
</div>
<p class="m-0">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
</div>
<h2 id="delete-instance-label" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
<ButtonStyled color="red">
<button
aria-labelledby="delete-instance-label"
:disabled="removing"
@click="deleteConfirmModal.show()"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
</div>
</template>
<style scoped lang="scss">

View File

@@ -101,57 +101,57 @@ const messages = defineMessages({
<template>
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
<h2 class="m-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hooks) }}
</h2>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
<p class="m-0">
{{ formatMessage(messages.hooksDescription) }}
</p>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="mt-2" />
<h2 class="mt-2 mb-1 text-lg font-extrabold text-contrast">
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<StyledInput
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.preLaunchEnter)"
wrapper-class="w-full mt-2"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapper) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<StyledInput
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.wrapperEnter)"
wrapper-class="w-full mt-2"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExit) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
<StyledInput
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full mt-2"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
</div>
</template>

View File

@@ -111,10 +111,10 @@ const messages = defineMessages({
<template>
<div>
<h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="m-0 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2" />
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2.5" />
<template v-if="!overrideJavaInstall">
<div class="flex my-2 items-center gap-2 font-semibold">
<template v-if="javaInstall">
@@ -144,10 +144,10 @@ const messages = defineMessages({
</div>
</template>
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2" />
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2.5" />
<Slider
id="max-memory"
v-model="memory.maximum"
@@ -159,7 +159,7 @@ const messages = defineMessages({
:snap-range="512"
unit="MB"
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
@@ -171,10 +171,10 @@ const messages = defineMessages({
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" />
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2.5" />
<StyledInput
id="env-vars"
v-model="envVars"

View File

@@ -94,14 +94,14 @@ const messages = defineMessages({
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<Checkbox
v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)"
/>
<div class="mt-2 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreen) }}
</h2>
<p class="m-0">
@@ -120,9 +120,9 @@ const messages = defineMessages({
/>
</div>
<div class="mt-4 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.width) }}
</h2>
<p class="m-0">
@@ -139,9 +139,9 @@ const messages = defineMessages({
/>
</div>
<div class="mt-4 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.height) }}
</h2>
<p class="m-0">

View File

@@ -177,7 +177,7 @@ const messages = defineMessages({
>
<ModrinthIcon class="w-6 h-6" />
</button>
<div>
<div class="max-w-[200px]">
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">macOS</span>

View File

@@ -21,7 +21,7 @@ watch(
)
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Color theme</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Color theme</h2>
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
<ThemeSelector
@@ -36,9 +36,9 @@ watch(
system-theme-color="system"
/>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Advanced rendering</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Advanced rendering</h2>
<p class="m-0 mt-1">
Enables advanced rendering such as blur effects that may cause performance issues without
hardware-accelerated rendering.
@@ -57,48 +57,48 @@ watch(
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
</div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Native decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Default landing page</h2>
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
</div>
<Combobox
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="w-40"
class="max-w-40"
:options="['Home', 'Library'].map((v) => ({ value: v, label: v }))"
:display-value="settings.default_page ?? 'Select an option'"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div>
<Toggle
@@ -113,9 +113,9 @@ watch(
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Toggle sidebar</h2>
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
</div>
<Toggle

View File

@@ -52,127 +52,135 @@ watch(
<template>
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Window size</h2>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Fullscreen</h3>
<p class="m-0 leading-tight">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Fullscreen</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Overwrites the options.txt file to start in full screen when launched.
</p>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Width</h3>
<p class="m-0 leading-tight">The width of the game window when launched.</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The width of the game window when launched.
</p>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Height</h3>
<p class="m-0 leading-tight">The height of the game window when launched.</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter height..."
/>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The height of the game window when launched.
</p>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Memory allocated</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter height..."
/>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Java arguments</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Environmental variables</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
</div>
</div>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
<hr class="my-6 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Memory allocated</h2>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran before the instance is launched.</p>
</div>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Wrapper hook</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Wrapper command for launching Minecraft.</p>
</div>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Pre launch</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran before the instance is launched.</p>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
/>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Wrapper</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Wrapper command for launching Minecraft.
</p>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
/>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Post exit</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran after the game closes.</p>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
/>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Post exit hook</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran after the game closes.</p>
</div>
</div>
</div>
</template>

View File

@@ -25,26 +25,28 @@ watch(
)
</script>
<template>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
</h2>
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent">
<button
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</button>
</ButtonStyled>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
<div class="flex flex-col gap-2.5 min-w-[600px]">
<div v-for="option in options" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
</h2>
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent">
<button
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</button>
</ButtonStyled>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
</div>
</div>
</div>
</template>

View File

@@ -21,15 +21,21 @@ async function updateJavaVersion(version) {
}
</script>
<template>
<div v-for="(javaVersion, index) in [25, 21, 17, 8]" :key="`java-${javaVersion}`">
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
<div class="flex flex-col gap-6">
<div
v-for="(javaVersion, index) in [25, 21, 17, 8]"
:key="`java-${javaVersion}`"
class="flex flex-col gap-2.5"
>
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
</div>
</div>
</template>

View File

@@ -43,7 +43,7 @@ async function onLocaleChange(newLocale: string) {
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Language</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Language</h2>
<Admonition type="warning" class="mt-2 mb-4">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}

View File

@@ -25,8 +25,8 @@ watch(
<template>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2>
<p class="m-0 text-sm">
<h2 class="m-0 text-lg font-semibold text-contrast">Personalized ads</h2>
<p class="m-0 mt-1 text-sm">
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests.
</p>
@@ -36,8 +36,8 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2>
<p class="m-0 text-sm">
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
<p class="m-0 mt-1 text-sm">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
@@ -48,8 +48,8 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2>
<p class="m-0 text-sm">
<h2 class="m-0 text-lg font-semibold text-contrast">Discord RPC</h2>
<p class="m-0 mt-1 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile.
</p>

View File

@@ -62,67 +62,77 @@ async function findLauncherDir() {
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">App directory</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<Button class="ml-1.5" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
</div>
<div class="m-1 my-2">
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</template>
</StyledInput>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
<p class="m-0 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">Maximum concurrent downloads</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">Maximum concurrent writes</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
</div>
</div>
<div>
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-extrabold text-contrast">App cache</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
<h2 class="m-0 text-lg font-extrabold text-contrast mt-4">Maximum concurrent downloads</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Maximum concurrent writes</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
<Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" />
</template>

View File

@@ -0,0 +1,92 @@
import { createConsoleState } from '@modrinth/ui'
import { clear_log_buffer, get_live_log_buffer, get_logs } from '@/helpers/logs'
type ConsoleState = ReturnType<typeof createConsoleState>
interface LogEntry {
filename: string
name?: string
log_type: string
stdout?: string
age?: number
live?: boolean
}
interface InstanceConsoleEntry {
liveConsole: ConsoleState
historicalConsole: ConsoleState
historicalCache: Map<string, string>
logList: LogEntry[] | null
}
const instances = new Map<string, InstanceConsoleEntry>()
function getOrCreate(profilePathId: string): InstanceConsoleEntry {
let entry = instances.get(profilePathId)
if (entry) return entry
entry = {
liveConsole: createConsoleState(),
historicalConsole: createConsoleState(),
historicalCache: new Map(),
logList: null,
}
instances.set(profilePathId, entry)
return entry
}
async function hydrate(profilePathId: string): Promise<void> {
const entry = getOrCreate(profilePathId)
if (entry.liveConsole.output.value.length > 0) return
const buffer = await get_live_log_buffer(profilePathId)
if (buffer) {
entry.liveConsole.addLegacyLog(buffer)
}
}
async function getHistoricalLogs(profilePathId: string, instancePath: string): Promise<LogEntry[]> {
const entry = getOrCreate(profilePathId)
if (entry.logList) return entry.logList
const logs: LogEntry[] = await get_logs(instancePath, false)
entry.logList = logs
for (const log of logs) {
if (log.stdout && log.stdout !== '') {
entry.historicalCache.set(log.filename, log.stdout)
}
}
return logs
}
function getHistoricalContent(profilePathId: string, filename: string): string | undefined {
return instances.get(profilePathId)?.historicalCache.get(filename)
}
function invalidate(profilePathId: string): void {
const entry = instances.get(profilePathId)
if (!entry) return
entry.historicalCache.clear()
entry.logList = null
}
async function destroy(profilePathId: string): Promise<void> {
instances.delete(profilePathId)
await clear_log_buffer(profilePathId).catch(() => {})
}
export function useInstanceConsole(profilePathId: string) {
const entry = getOrCreate(profilePathId)
return {
liveConsole: entry.liveConsole,
historicalConsole: entry.historicalConsole,
hydrate: () => hydrate(profilePathId),
getHistoricalLogs: (instancePath: string) => getHistoricalLogs(profilePathId, instancePath),
getHistoricalContent: (filename: string) => getHistoricalContent(profilePathId, filename),
invalidate: () => invalidate(profilePathId),
destroy: () => destroy(profilePathId),
}
}

View File

@@ -0,0 +1,18 @@
const trimTrailingSlash = (url: string) => url.replace(/\/$/, '')
const siteUrl = trimTrailingSlash(import.meta.env.MODRINTH_URL || 'https://modrinth.com')
const labrinthBaseUrl = trimTrailingSlash(
import.meta.env.MODRINTH_API_BASE_URL || 'https://api.modrinth.com',
)
const archonBaseUrl = trimTrailingSlash(
import.meta.env.MODRINTH_ARCHON_BASE_URL || 'https://archon.modrinth.com',
)
export const config = {
siteUrl,
stripePublishableKey:
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ||
'pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b',
labrinthBaseUrl,
archonBaseUrl,
}

View File

@@ -97,3 +97,23 @@ export async function warning_listener(callback) {
export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload))
}
/// Payload for the 'log' event
/*
LogPayload {
profile_path_id: string,
type: "log4j" | "legacy",
// log4j fields (when type === "log4j"):
timestamp_millis?: number,
logger_name?: string,
level?: string,
thread_name?: string,
message?: string,
throwable?: string,
// legacy fields (when type === "legacy"):
message?: string,
}
*/
export async function log_listener(callback) {
return await listen('log', (event) => callback(event.payload))
}

View File

@@ -63,3 +63,13 @@ export async function delete_logs(profilePath) {
export async function get_latest_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
}
/// Get all buffered live log lines for a profile from the Rust ring buffer
export async function get_live_log_buffer(profilePath) {
return await invoke('plugin:logs|logs_get_live_log_buffer', { profilePath })
}
/// Clear the live log buffer for a profile on the Rust side
export async function clear_log_buffer(profilePath) {
return await invoke('plugin:logs|logs_clear_live_log_buffer', { profilePath })
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { config } from '../config'
const stripePublishableKey = (config.stripePublishableKey as string) || ''
const client = injectModrinthClient()
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
})
const resolvedProducts = computed<Labrinth.Billing.Internal.Product[]>(() => products.value ?? [])
</script>
<template>
<ServersManagePageIndex
:stripe-publishable-key="stripePublishableKey"
:products="resolvedProducts"
/>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import { injectModrinthServerContext, ServersManageBackupsPage } from '@modrinth/ui'
const { isServerRunning } = injectModrinthServerContext()
</script>
<template>
<ServersManageBackupsPage :is-server-running="isServerRunning" />
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { ServersManageContentPage } from '@modrinth/ui'
</script>
<template>
<ServersManageContentPage />
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { ServersManageFilesPage } from '@modrinth/ui'
</script>
<template>
<ServersManageFilesPage />
</template>

View File

@@ -0,0 +1,113 @@
<template>
<div class="h-full w-full py-6">
<ServersManageRootLayout
:server-id="serverId"
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
({ serverId: sid, worldId: wid, from }) => {
router.push({
path: '/browse/modpack',
query: { sid, wid: wid ?? undefined, from },
})
}
"
:browse-content="
({ serverId: sid, worldId: wid, type }) => {
router.push({
path: `/browse/${type}`,
query: { sid, wid: wid ?? undefined },
})
}
"
>
<template #default="{ onReinstall, onReinstallFailed }">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense>
<component
:is="Component"
@reinstall="onReinstall"
@reinstall-failed="onReinstallFailed"
/>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</template>
</ServersManageRootLayout>
</div>
</template>
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, LoadingIndicator, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/theme'
const route = useRoute()
const router = useRouter()
const auth = injectAuth()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
})
const { data: serverData } = useQuery({
queryKey: computed(() => ['servers', 'detail', serverId.value]),
queryFn: () => null as unknown as Archon.Servers.v0.Server,
enabled: false,
})
watch(
serverData,
(server) => {
if (server?.name) {
breadcrumbs.setName('Server', server.name)
breadcrumbs.setContext({
name: server.name,
link: `/hosting/manage/${serverId.value}/content`,
})
}
},
{ immediate: true },
)
watch(
() => auth.user.value,
(user, previousUser) => {
if (user || !previousUser) return
if (route.path === '/hosting/manage' || route.path === '/hosting/manage/') return
void router.replace('/hosting/manage')
},
)
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
const credentials = await getCreds().catch(() => null)
if (!credentials?.user_id) {
return { userId: null, userRole: null }
}
const user = await get_user(credentials.user_id, 'bypass').catch(() => null)
const typedUser = user as Labrinth.Users.v2.User | null
return {
userId: credentials.user_id,
userRole: typedUser?.role ?? null,
}
}
</script>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { ServersManageOverviewPage } from '@modrinth/ui'
</script>
<template>
<ServersManageOverviewPage />
</template>

View File

@@ -0,0 +1,7 @@
import Backups from './Backups.vue'
import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
export { Backups, Content, Files, Index, Overview }

View File

@@ -1,6 +1,7 @@
import Browse from './Browse.vue'
import Index from './Index.vue'
import Servers from './Servers.vue'
import Skins from './Skins.vue'
import Worlds from './Worlds.vue'
export { Browse, Index, Skins, Worlds }
export { Browse, Index, Servers, Skins, Worlds }

View File

@@ -114,9 +114,9 @@
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<button :disabled="stopping" @click="stopInstance('InstancePage')">
<StopCircleIcon />
Stop
{{ stopping ? 'Stopping...' : 'Stop' }}
</button>
</ButtonStyled>
<ButtonStyled
@@ -172,7 +172,7 @@
color="brand"
size="large"
>
<button disabled>Loading...</button>
<button disabled>Starting...</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
@@ -312,6 +312,7 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
@@ -345,6 +346,7 @@ window.addEventListener('online', () => {
const instance = ref<GameInstance>()
const playing = ref(false)
const loading = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
@@ -494,8 +496,10 @@ const startInstance = async (context: string) => {
}
const stopInstance = async (context: string) => {
playing.value = false
stopping.value = true
await kill(route.params.id as string).catch(handleError)
stopping.value = false
playing.value = false
if (!instance.value) return
trackEvent('InstanceStop', {
@@ -644,6 +648,11 @@ const timePlayedHumanized = computed(() => {
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
const profilePath = route.params.id
if (profilePath) {
const { destroy } = useInstanceConsole(profilePath)
destroy()
}
})
</script>

View File

@@ -1,127 +1,24 @@
<template>
<Card class="log-card">
<div class="button-row">
<DropdownSelect
v-model="selectedLogIndex"
:default-value="0"
name="Log date"
:options="logs.map((_, index) => index)"
:display-name="(option) => logs[option]?.name"
:disabled="logs.length === 0"
/>
<div class="button-group">
<Button :disabled="!logs[selectedLogIndex]" @click="copyLog()">
<ClipboardCopyIcon v-if="!copied" />
<CheckIcon v-else />
{{ copied ? 'Copied' : 'Copy' }}
</Button>
<Button color="primary" :disabled="offline || !logs[selectedLogIndex]" @click="share">
<ShareIcon aria-hidden="true" />
Share
</Button>
<Button
v-if="logs[selectedLogIndex] && logs[selectedLogIndex].live === true"
@click="clearLiveLog()"
>
<TrashIcon aria-hidden="true" />
Clear
</Button>
<Button
v-else
:disabled="!logs[selectedLogIndex] || logs[selectedLogIndex].live === true"
color="danger"
@click="deleteLog()"
>
<TrashIcon aria-hidden="true" />
Delete
</Button>
</div>
</div>
<div class="button-row">
<StyledInput
id="text-filter"
v-model="searchFilter"
autocomplete="off"
:icon="SearchIcon"
type="search"
input-class="text-filter"
placeholder="Type to filter logs..."
/>
<div class="filter-group">
<Checkbox
v-for="level in levels"
:key="level.toLowerCase()"
v-model="levelFilters[level.toLowerCase()]"
class="filter-checkbox"
>
{{ level }}
</Checkbox>
</div>
</div>
<div class="log-text">
<RecycleScroller
v-slot="{ item }"
ref="logContainer"
class="scroller"
:items="displayProcessedLogs"
direction="vertical"
:item-size="20"
key-field="id"
buffer="200"
>
<div class="user no-wrap">
<span :style="{ color: item.prefixColor, 'font-weight': item.weight }">{{
item.prefix
}}</span>
<span :style="{ color: item.textColor }">{{ item.text }}</span>
</div>
</RecycleScroller>
</div>
<ShareModalWrapper
ref="shareModal"
header="Share Log"
share-title="Instance Log"
share-text="Check out this log from an instance on the Modrinth App"
:open-in-new-tab="false"
link
/>
</Card>
<div class="flex flex-col gap-4 h-full">
<ConsolePageLayout />
</div>
</template>
<script setup>
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { CheckIcon, ClipboardCopyIcon, SearchIcon, ShareIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
Card,
Checkbox,
DropdownSelect,
ConsolePageLayout,
injectModrinthClient,
injectNotificationManager,
StyledInput,
provideConsoleManager,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import isToday from 'dayjs/plugin/isToday'
import isYesterday from 'dayjs/plugin/isYesterday'
import { ofetch } from 'ofetch'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { RecycleScroller } from 'vue-virtual-scroller'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { process_listener } from '@/helpers/events.js'
import {
delete_logs_by_filename,
get_latest_log_cursor,
get_logs,
get_output_by_filename,
} from '@/helpers/logs.js'
import { get_by_profile_path } from '@/helpers/process.js'
dayjs.extend(isToday)
dayjs.extend(isYesterday)
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { log_listener, process_listener } from '@/helpers/events.js'
import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js'
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const route = useRoute()
@@ -158,414 +55,179 @@ const props = defineProps({
},
})
const currentLiveLog = ref(null)
const currentLiveLogCursor = ref(0)
const emptyText = ['No live game detected.', 'Start your game to proceed.']
const profilePathId = computed(() => route.params.id)
const {
liveConsole,
historicalConsole,
hydrate,
getHistoricalLogs,
getHistoricalContent,
invalidate,
} = useInstanceConsole(profilePathId.value)
const logs = ref([])
await setLogs()
await hydrate()
const logsColored = true
function buildLogList(rawLogs) {
return [
{ name: 'Live Log', live: true },
...rawLogs
.filter(
(log) =>
log.filename !== 'latest_stdout.log' &&
log.filename !== 'latest_stdout' &&
log.filename !== 'launcher_log.txt' &&
log.stdout !== '' &&
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
)
.map((log) => ({
...log,
name: log.filename || 'Unknown',
})),
]
}
const logs = ref(buildLogList([]))
void getHistoricalLogs(props.instance.path)
.then((allLogs) => {
logs.value = buildLogList(allLogs)
})
.catch(handleError)
const selectedLogIndex = ref(0)
const copied = ref(false)
const logContainer = ref(null)
const interval = ref(null)
const userScrolled = ref(false)
const isAutoScrolling = ref(false)
const shareModal = ref(null)
const isLive = computed(() => selectedLogIndex.value === 0)
const levels = ['Comment', 'Error', 'Warn', 'Info', 'Debug', 'Trace']
const levelFilters = ref({})
levels.forEach((level) => {
levelFilters.value[level.toLowerCase()] = true
const filteredLogs = computed(() =>
props.playing ? logs.value.filter((l) => l.live || l.name !== 'latest.log') : logs.value,
)
const logSources = computed(() =>
filteredLogs.value.map((l, i) => ({
id: String(i),
name: l?.name ?? `Log ${i}`,
live: l?.live ?? false,
})),
)
const activeConsole = computed(() => (isLive.value ? liveConsole : historicalConsole))
const logLines = shallowRef(activeConsole.value.output.value)
watchEffect(() => {
logLines.value = activeConsole.value.output.value
triggerRef(logLines)
})
const searchFilter = ref('')
function shouldDisplay(processedLine) {
if (!processedLine.level) {
return true
}
const crashAnalysis = ref(null)
if (!levelFilters.value[processedLine.level.toLowerCase()]) {
return false
}
if (searchFilter.value !== '') {
if (!processedLine.text.toLowerCase().includes(searchFilter.value.toLowerCase())) {
return false
async function analyseForCrash() {
const lines = liveConsole.output.value
if (lines.length === 0) return
const content = lines.map((l) => l.text).join('\n')
try {
const data = await client.mclogs.insights_v1.analyse(content)
if (data.analysis?.problems?.length > 0) {
crashAnalysis.value = data
}
} catch {
// Crash analysis is best-effort
}
return true
}
// Selects from the processed logs which ones should be displayed (shouldDisplay)
// In addition, splits each line by \n. Each split line is given the same properties as the original line
const displayProcessedLogs = computed(() => {
return processedLogs.value.filter((l) => shouldDisplay(l))
const selectedLog = computed(() => filteredLogs.value[selectedLogIndex.value])
const deleteDisabled = computed(() => {
const log = selectedLog.value
if (!log || log.live) return true
return log.filename === 'latest.log' && props.playing
})
const processedLogs = computed(() => {
// split based on newline and timestamp lookahead
// (not just newline because of multiline messages)
const splitPattern = /\n(?=(?:#|\[\d\d:\d\d:\d\d\]))/
const lines = logs.value[selectedLogIndex.value]?.stdout.split(splitPattern) || []
const processed = []
let id = 0
for (let i = 0; i < lines.length; i++) {
// Then split off of \n.
// Lines that are not the first have prefix = null
const text = getLineText(lines[i])
const prefix = getLinePrefix(lines[i])
const prefixColor = getLineColor(lines[i], true)
const textColor = getLineColor(lines[i], false)
const weight = getLineWeight(lines[i])
const level = getLineLevel(lines[i])
text.split('\n').forEach((line, index) => {
processed.push({
id: id,
text: line,
prefix: index === 0 ? prefix : null,
prefixColor: prefixColor,
textColor: textColor,
weight: weight,
level: level,
})
id += 1
})
}
return processed
})
async function getLiveStdLog() {
if (route.params.id) {
const processes = await get_by_profile_path(route.params.id).catch(handleError)
let returnValue
if (processes.length === 0) {
returnValue = emptyText.join('\n')
} else {
const logCursor = await get_latest_log_cursor(
props.instance.path,
currentLiveLogCursor.value,
).catch(handleError)
if (logCursor.new_file) {
currentLiveLog.value = ''
}
currentLiveLog.value = currentLiveLog.value + logCursor.output
currentLiveLogCursor.value = logCursor.cursor
returnValue = currentLiveLog.value
}
return { name: 'Live Log', stdout: returnValue, live: true }
}
return null
}
async function getLogs() {
return (await get_logs(props.instance.path, true).catch(handleError))
.filter(
// filter out latest_stdout.log or anything without .log in it
(log) =>
log.filename !== 'latest_stdout.log' &&
log.filename !== 'latest_stdout' &&
log.stdout !== '' &&
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
)
.map((log) => {
log.name = log.filename || 'Unknown'
log.stdout = 'Loading...'
return log
})
}
async function setLogs() {
const [liveStd, allLogs] = await Promise.all([getLiveStdLog(), getLogs()])
logs.value = [liveStd, ...allLogs]
}
const copyLog = () => {
if (logs.value.length > 0 && logs.value[selectedLogIndex.value]) {
navigator.clipboard.writeText(logs.value[selectedLogIndex.value].stdout)
copied.value = true
}
}
const share = async () => {
if (logs.value.length > 0 && logs.value[selectedLogIndex.value]) {
const url = await ofetch('https://api.mclo.gs/1/log', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `content=${encodeURIComponent(logs.value[selectedLogIndex.value].stdout)}`,
}).catch(handleError)
shareModal.value.show(url.url)
}
}
watch(selectedLogIndex, async (newIndex) => {
copied.value = false
userScrolled.value = false
if (logs.value.length > 1 && newIndex !== 0) {
logs.value[newIndex].stdout = 'Loading...'
logs.value[newIndex].stdout = await get_output_by_filename(
props.instance.path,
logs.value[newIndex].log_type,
logs.value[newIndex].filename,
).catch(handleError)
}
})
if (logs.value.length > 1 && !props.playing) {
selectedLogIndex.value = 1
} else {
async function deleteSelectedLog() {
const log = selectedLog.value
if (!log || log.live) return
await delete_logs_by_filename(props.instance.path, log.log_type, log.filename)
invalidate()
const freshLogs = await getHistoricalLogs(props.instance.path)
logs.value = buildLogList(freshLogs)
selectedLogIndex.value = 0
}
const deleteLog = async () => {
if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) {
const deleteIndex = selectedLogIndex.value
selectedLogIndex.value = deleteIndex - 1
await delete_logs_by_filename(
props.instance.path,
logs.value[deleteIndex].log_type,
logs.value[deleteIndex].filename,
).catch(handleError)
await setLogs()
provideConsoleManager({
logLines,
logSources,
activeLogSourceIndex: selectedLogIndex,
showCommandInput: false,
loading: ref(false),
onClear: () => {
activeConsole.value.clear()
},
onDelete: deleteSelectedLog,
deleteDisabled,
deleteDisabledTooltip: 'Cannot delete latest.log while the instance is running',
shareDisabled: computed(() => props.offline),
emptyStateType: 'instance',
crashAnalysis,
onDismissCrash: () => {
crashAnalysis.value = null
},
})
watch(selectedLogIndex, async (newIndex) => {
if (newIndex === 0) return
const log = filteredLogs.value[newIndex]
if (!log) return
const cached = getHistoricalContent(log.filename)
if (cached) {
historicalConsole.clear()
historicalConsole.addLegacyLog(cached)
return
}
const output = await get_output_by_filename(
props.instance.path,
log.log_type,
log.filename,
).catch(handleError)
if (output) {
historicalConsole.clear()
historicalConsole.addLegacyLog(output)
}
})
selectedLogIndex.value = 0
if (!props.playing) {
void analyseForCrash()
}
const clearLiveLog = async () => {
currentLiveLog.value = ''
// does not reset cursor
}
const unlistenLog = await log_listener((payload) => {
if (payload.profile_path_id !== profilePathId.value) return
const isLineLevel = (text, level) => {
if ((text.includes('/INFO') || text.includes('[System] [CHAT]')) && level === 'info') {
return true
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
} else if (payload.type === 'legacy') {
liveConsole.addLegacyLog(payload.message)
}
if (text.includes('/WARN') && level === 'warn') {
return true
}
if (text.includes('/DEBUG') && level === 'debug') {
return true
}
if (text.includes('/TRACE') && level === 'trace') {
return true
}
const errorTriggers = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', ' at']
if (level === 'error') {
for (const trigger of errorTriggers) {
if (text.includes(trigger)) return true
}
}
if (text.trim()[0] === '#' && level === 'comment') {
return true
}
return false
}
const getLineWeight = (text) => {
if (
!logsColored ||
isLineLevel(text, 'info') ||
isLineLevel(text, 'debug') ||
isLineLevel(text, 'trace')
) {
return 'normal'
}
if (isLineLevel(text, 'error') || isLineLevel(text, 'warn')) {
return 'bold'
}
}
const getLineLevel = (text) => {
for (const level of levels) {
if (isLineLevel(text, level.toLowerCase())) {
return level
}
}
}
const getLineColor = (text, prefix) => {
if (isLineLevel(text, 'comment')) {
return 'var(--color-green)'
}
if (!logsColored || text.includes('[System] [CHAT]')) {
return 'var(--color-white)'
}
if (
(isLineLevel(text, 'info') || isLineLevel(text, 'debug') || isLineLevel(text, 'trace')) &&
prefix
) {
return 'var(--color-blue)'
}
if (isLineLevel(text, 'warn')) {
return 'var(--color-orange)'
}
if (isLineLevel(text, 'error')) {
return 'var(--color-red)'
}
}
const getLinePrefix = (text) => {
if (text.includes(']:')) {
return text.split(']:')[0] + ']:'
}
}
const getLineText = (text) => {
if (text.includes(']:')) {
if (text.split(']:').length > 2) {
return text.split(']:').slice(1).join(']:')
}
return text.split(']:')[1]
} else {
return text
}
}
function handleUserScroll() {
if (!isAutoScrolling.value) {
userScrolled.value = true
}
}
interval.value = setInterval(async () => {
if (logs.value.length > 0) {
logs.value[0] = await getLiveStdLog()
const scroll = logContainer.value.getScroll()
// Allow resetting of userScrolled if the user scrolls to the bottom
if (selectedLogIndex.value === 0) {
if (scroll.end >= logContainer.value.$el.scrollHeight - 10) userScrolled.value = false
if (!userScrolled.value) {
await nextTick()
isAutoScrolling.value = true
logContainer.value.scrollToItem(displayProcessedLogs.value.length - 1)
setTimeout(() => (isAutoScrolling.value = false), 50)
}
}
}
}, 250)
})
const unlistenProcesses = await process_listener(async (e) => {
if (e.profile_path_id !== profilePathId.value) return
if (e.event === 'launched') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
liveConsole.clear()
invalidate()
selectedLogIndex.value = 0
}
if (e.event === 'finished') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
userScrolled.value = false
await setLogs()
selectedLogIndex.value = 1
invalidate()
const freshLogs = await getHistoricalLogs(props.instance.path)
logs.value = buildLogList(freshLogs)
void analyseForCrash()
}
})
onMounted(() => {
logContainer.value.$el.addEventListener('scroll', handleUserScroll)
})
onBeforeUnmount(() => {
logContainer.value.$el.removeEventListener('scroll', handleUserScroll)
})
onUnmounted(() => {
clearInterval(interval.value)
unlistenLog()
unlistenProcesses()
})
</script>
<style scoped lang="scss">
.log-card {
display: flex;
flex-direction: column;
gap: 1rem;
height: 100vh;
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 0.5rem;
}
.button-group {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.log-text {
width: 100%;
height: 100%;
font-family: var(--mono-font);
background-color: var(--color-accent-contrast);
color: var(--color-contrast);
border-radius: var(--radius-lg);
padding-top: 1.5rem;
overflow-x: auto; /* Enables horizontal scrolling */
overflow-y: hidden; /* Disables vertical scrolling on this wrapper */
white-space: nowrap; /* Keeps content on a single line */
white-space: normal;
color-scheme: dark;
}
.filter-checkbox {
margin-bottom: 0.3rem;
font-size: 1rem;
svg {
display: flex;
align-self: center;
justify-self: center;
}
}
.filter-group {
display: flex;
padding: 0.6rem;
flex-direction: row;
overflow: auto;
gap: 0.5rem;
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb {
border-radius: 10px;
}
}
:deep(.vue-recycle-scroller__item-wrapper) {
overflow: visible; /* Enables horizontal scrolling */
}
:deep(.vue-recycle-scroller) {
&::-webkit-scrollbar-corner {
background-color: var(--color-bg);
border-radius: 0 0 10px 0;
}
}
.scroller {
height: 100%;
}
.user {
height: 32%;
padding: 0 1.5rem;
display: flex;
align-items: center;
user-select: text;
}
</style>

View File

@@ -357,9 +357,7 @@ const MAX_LINUX_REFRESHES = 3
const isLinux = platform() === 'linux'
const linuxRefreshCount = ref(0)
const protocolVersion = ref<ProtocolVersion | null>(
await get_profile_protocol_version(instance.value.path),
)
const protocolVersion = ref<ProtocolVersion | null>(null)
const managedServerName = ref<string | null>(null)
const managedServerAddress = ref<string | null>(null)
@@ -424,22 +422,27 @@ watch(
{ immediate: true },
)
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
const [unlistenProfile, , resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
if (isLinux) linuxRefreshCount.value++
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
if (isLinux) linuxRefreshCount.value++
await refreshAllWorlds()
}
await refreshAllWorlds()
}
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
})
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
}),
refreshAllWorlds(),
get_profile_protocol_version(instance.value.path).catch(() => null),
get_game_versions().catch(() => [] as GameVersion[]),
])
await refreshAllWorlds()
protocolVersion.value = resolvedProtocolVersion
async function refreshServer(address: string) {
if (!serverData.value[address]) {
@@ -589,7 +592,7 @@ function worldsMatch(world: World, other: World | undefined) {
return false
}
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const gameVersions = ref<GameVersion[]>(resolvedGameVersions)
const supportsServerQuickPlay = computed(() =>
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)

View File

@@ -1,6 +1,6 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { type Ref, ref, watchEffect } from 'vue'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
session?: string | null
@@ -13,10 +13,12 @@ export function setupAuthProvider(
) {
const sessionToken = ref<string | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const isReady = computed(() => credentials.value !== undefined)
const authProvider: AuthProvider = {
session_token: sessionToken,
user,
isReady,
requestSignIn,
}

View File

@@ -0,0 +1,393 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import {
createContext,
type CreationFlowContextValue,
injectModrinthClient,
injectNotificationManager,
} from '@modrinth/ui'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
type ServerFlowFrom = 'onboarding' | 'reset-server'
type ServerInstallableType = 'modpack' | 'mod' | 'plugin' | 'datapack'
type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
installing?: boolean
installed?: boolean
}
interface ServerModpackSelectionRequest {
projectId: string
versionId: string
name: string
iconUrl?: string
}
interface ServerSetupModalHandle {
show: () => void | Promise<void>
hide: () => void
ctx?: CreationFlowContextValue | null
}
export interface ServerInstallContentContext {
serverIdQuery: ComputedRef<string | null>
worldIdQuery: ComputedRef<string | null>
browseFrom: ComputedRef<string | null>
serverFlowFrom: ComputedRef<ServerFlowFrom | null>
isFromWorlds: ComputedRef<boolean>
isServerContext: ComputedRef<boolean>
isSetupServerContext: ComputedRef<boolean>
effectiveServerWorldId: ComputedRef<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContentProjectIds: Ref<Set<string>>
serverBackUrl: ComputedRef<string>
serverBackLabel: ComputedRef<string>
serverBrowseHeading: ComputedRef<string>
initServerContext: () => Promise<void>
watchServerContextChanges: () => void
searchServerModpacks: (
query: string,
limit?: number,
) => Promise<Labrinth.Projects.v2.SearchResult>
getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]>
enforceSetupModpackRoute: (currentProjectType: string | undefined) => void
installProjectToServer: (project: InstallableSearchResult) => Promise<boolean>
onServerFlowBack: () => void
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void>
markServerProjectInstalled: (id: string) => void
}
export const [injectServerInstallContent, provideServerInstallContent] =
createContext<ServerInstallContentContext>('Browse', 'serverInstallContent')
function readQueryString(value: unknown): string | null {
if (Array.isArray(value)) return value[0] ?? null
return typeof value === 'string' && value.length > 0 ? value : null
}
export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
}) {
const { serverSetupModalRef } = opts
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const serverIdQuery = computed(() => readQueryString(route.query.sid))
const worldIdQuery = computed(() => readQueryString(route.query.wid))
const browseFrom = computed(() => readQueryString(route.query.from))
const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
? browseFrom.value
: null,
)
const isFromWorlds = computed(() => browseFrom.value === 'worlds')
const isServerContext = computed(() => !!serverIdQuery.value)
const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value)
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
const serverContentProjectIds = ref<Set<string>>(new Set())
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
const serverBackUrl = computed(() => {
const sid = serverIdQuery.value
if (!sid) return '/hosting/manage'
if (serverFlowFrom.value === 'onboarding') {
return `/hosting/manage/${sid}?resumeModal=setup-type`
}
if (serverFlowFrom.value === 'reset-server') {
return `/hosting/manage/${sid}?openSettings=installation`
}
return `/hosting/manage/${sid}/content`
})
const serverBackLabel = computed(() => {
if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
if (serverFlowFrom.value === 'reset-server') return 'Cancel reset'
return 'Back to server'
})
const serverBrowseHeading = computed(() => {
if (serverFlowFrom.value === 'reset-server') {
return 'Select modpack to install after reset'
}
return 'Install content to server'
})
async function resolveServerContextWorldId(serverId: string) {
try {
const server = await client.archon.servers_v1.get(serverId)
const activeWorld = server.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server.worlds[0]?.id ?? null
} catch (err) {
handleError(err as Error)
return null
}
}
async function refreshServerInstalledContent(serverId: string, worldId: string) {
try {
const content = await client.archon.content_v1.getAddons(serverId, worldId)
const ids = new Set(
(content.addons ?? [])
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
serverContentProjectIds.value = ids
} catch (err) {
handleError(err as Error)
}
}
async function initServerContext() {
const sid = serverIdQuery.value
if (!sid) return
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
}
let resolvedWorldId = effectiveServerWorldId.value
if (!resolvedWorldId) {
resolvedWorldId = await resolveServerContextWorldId(sid)
if (resolvedWorldId) {
serverContextWorldId.value = resolvedWorldId
}
}
if (resolvedWorldId) {
await refreshServerInstalledContent(sid, resolvedWorldId)
}
}
function watchServerContextChanges() {
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
if (!sid) {
serverContextServerData.value = null
serverContentProjectIds.value = new Set()
return
}
if (sid !== prevSid) {
serverContentProjectIds.value = new Set()
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
}
}
if (wid && (sid !== prevSid || wid !== prevWid)) {
await refreshServerInstalledContent(sid, wid)
}
})
}
function normalizeLoader(loader: string) {
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
}
function getCompatibleLoaders(loader: string) {
const normalized = normalizeLoader(loader)
if (!normalized) return new Set<string>()
if (normalized === 'paper' || normalized === 'purpur' || normalized === 'spigot') {
return new Set(['paper', 'purpur', 'spigot', 'bukkit'])
}
if (normalized === 'neoforge' || normalized === 'neo') {
return new Set(['neoforge', 'neo'])
}
return new Set([normalized])
}
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
router.replace({
path: '/browse/modpack',
query: route.query,
})
}
async function searchServerModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({
query: query || undefined,
new_filters:
'project_types = "modpack" AND (client_side = "optional" OR client_side = "required") AND server_side = "required"',
limit,
})
}
async function getServerProjectVersions(projectId: string) {
const versions = await client.labrinth.versions_v3.getProjectVersions(projectId)
return versions.map((version) => ({ id: version.id }))
}
async function openServerModpackInstallFlow(request: ServerModpackSelectionRequest) {
if (!serverIdQuery.value || !effectiveServerWorldId.value) {
throw new Error('Missing server context')
}
const modalInstance = serverSetupModalRef.value
if (!modalInstance) return
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
if (!ctx) return
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: request.projectId,
versionId: request.versionId,
name: request.name,
iconUrl: request.iconUrl,
}
ctx.modal.value?.setStage('final-config')
}
function getCurrentServerInstallType(): ServerInstallableType {
const raw = Array.isArray(route.params.projectType)
? route.params.projectType[0]
: route.params.projectType
if (raw === 'modpack' || raw === 'mod' || raw === 'plugin' || raw === 'datapack') {
return raw
}
throw new Error('This content type cannot be installed to a server from browse.')
}
async function installProjectToServer(project: InstallableSearchResult) {
const contentType = getCurrentServerInstallType()
const sid = serverIdQuery.value
const wid = effectiveServerWorldId.value
if (!sid || !wid) {
throw new Error('No server world is available for install.')
}
if (contentType === 'modpack') {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const versionId = versions[0]?.id ?? project.version_id
if (!versionId) {
throw new Error('No version found for this modpack')
}
await openServerModpackInstallFlow({
projectId: project.project_id,
versionId,
name: project.name,
iconUrl: project.icon_url ?? undefined,
})
return false
}
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const serverLoader = (serverContextServerData.value?.loader ?? '').toLowerCase()
const serverGameVersion = (serverContextServerData.value?.mc_version ?? '').trim()
const compatibleLoaders = getCompatibleLoaders(serverLoader)
const hasGameVersionMatch = (version: Labrinth.Versions.v2.Version) =>
!serverGameVersion || version.game_versions.includes(serverGameVersion)
const hasLoaderMatch = (version: Labrinth.Versions.v2.Version) => {
if (contentType === 'datapack') return true
if (compatibleLoaders.size === 0) return true
return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoader(loader)))
}
let matchingVersion = versions.find(
(version) => hasGameVersionMatch(version) && hasLoaderMatch(version),
)
if (!matchingVersion) {
matchingVersion = versions.find((version) => hasLoaderMatch(version))
}
if (!matchingVersion) {
matchingVersion = versions.find((version) => hasGameVersionMatch(version))
}
if (!matchingVersion) {
matchingVersion = versions[0]
}
if (!matchingVersion) {
throw new Error('No installable version was found for this project.')
}
await client.archon.content_v1.addAddon(sid, wid, {
project_id: matchingVersion.project_id,
version_id: matchingVersion.id,
})
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, project.project_id])
return true
}
function onServerFlowBack() {
serverSetupModalRef.value?.hide()
}
async function handleServerModpackFlowCreate(config: CreationFlowContextValue) {
const sid = serverIdQuery.value
const wid = effectiveServerWorldId.value
if (!sid || !wid || !config.modpackSelection.value) {
config.loading.value = false
return
}
try {
await client.archon.content_v1.installContent(sid, wid, {
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)
serverSetupModalRef.value?.hide()
if (serverFlowFrom.value === 'onboarding') {
await client.archon.servers_v1.endIntro(sid)
await router.push(`/hosting/manage/${sid}/content`)
return
}
await router.push(`/hosting/manage/${sid}?openSettings=installation`)
} catch (err) {
handleError(err as Error)
config.loading.value = false
}
}
function markServerProjectInstalled(id: string) {
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id])
}
return {
serverIdQuery,
worldIdQuery,
browseFrom,
serverFlowFrom,
isFromWorlds,
isServerContext,
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
serverBackUrl,
serverBackLabel,
serverBrowseHeading,
initServerContext,
watchServerContextChanges,
searchServerModpacks,
getServerProjectVersions,
enforceSetupModpackRoute,
installProjectToServer,
onServerFlowBack,
handleServerModpackFlowCreate,
markServerProjectInstalled,
}
}

View File

@@ -1,7 +1,7 @@
import { ServersManagePageIndex } from '@modrinth/ui'
import { createRouter, createWebHistory } from 'vue-router'
import * as Pages from '@/pages'
import * as Hosting from '@/pages/hosting/manage'
import * as Instance from '@/pages/instance'
import * as Library from '@/pages/library'
import * as Project from '@/pages/project'
@@ -31,11 +31,50 @@ export default new createRouter({
{
path: '/hosting/manage/',
name: 'Servers',
component: ServersManagePageIndex,
component: Pages.Servers,
meta: {
breadcrumb: [{ name: 'Servers' }],
},
},
{
path: '/hosting/manage/:id',
name: 'ServerManage',
component: Hosting.Index,
children: [
{
path: '',
name: 'ServerManageOverview',
component: Hosting.Overview,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'content',
name: 'ServerManageContent',
component: Hosting.Content,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'files',
name: 'ServerManageFiles',
component: Hosting.Files,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'backups',
name: 'ServerManageBackups',
component: Hosting.Backups,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
{
path: '/browse/:projectType',
name: 'Discover content',
@@ -88,6 +127,13 @@ export default new createRouter({
},
],
},
{
path: '/:projectType(mod|plugin|datapack|resourcepack|shader|modpack)/:id/:rest(.*)*',
redirect: (to) => {
const rest = to.params.rest ? `/${[].concat(to.params.rest).join('/')}` : ''
return `/project/${to.params.id}${rest}${to.hash}`
},
},
{
path: '/project/:id',
name: 'Project',
@@ -202,7 +248,8 @@ export default new createRouter({
],
linkActiveClass: 'router-link-active',
linkExactActiveClass: 'router-link-exact-active',
scrollBehavior() {
scrollBehavior(to, from) {
if (to.path === from.path) return
// Sometimes Vue's scroll behavior is not working as expected, so we need to manually scroll to top (especially on Linux)
document.querySelector('.app-viewport')?.scrollTo(0, 0)
return {

View File

@@ -5,7 +5,6 @@ export const DEFAULT_FEATURE_FLAGS = {
page_path: false,
worlds_tab: false,
worlds_in_home: true,
servers_in_app: false,
server_project_qa: false,
i18n_debug: false,
}