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:
3
.github/workflows/theseus-build.yml
vendored
3
.github/workflows/theseus-build.yml
vendored
@@ -27,12 +27,15 @@ on:
|
|||||||
options:
|
options:
|
||||||
- prod
|
- prod
|
||||||
- staging
|
- staging
|
||||||
|
- prod-with-staging-archon
|
||||||
default: prod
|
default: prod
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
|
env:
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY: pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -29,5 +29,7 @@
|
|||||||
},
|
},
|
||||||
"[rust]": {
|
"[rust]": {
|
||||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||||
}
|
},
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
"scss.lint.unknownAtRules": "ignore"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { AuthFeature, PanelVersionFeature, TauriModrinthClient } from '@modrinth/api-client'
|
import {
|
||||||
|
AuthFeature,
|
||||||
|
NodeAuthFeature,
|
||||||
|
nodeAuthState,
|
||||||
|
PanelVersionFeature,
|
||||||
|
TauriModrinthClient,
|
||||||
|
VerboseLoggingFeature,
|
||||||
|
} from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
ArrowBigUpDashIcon,
|
ArrowBigUpDashIcon,
|
||||||
ChangeSkinIcon,
|
ChangeSkinIcon,
|
||||||
@@ -19,7 +26,7 @@ import {
|
|||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
RestoreIcon,
|
RestoreIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
ServerIcon,
|
ServerStackIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
WorldIcon,
|
WorldIcon,
|
||||||
@@ -80,6 +87,7 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
|||||||
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
||||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
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 { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||||
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
||||||
import { check_reachable } from '@/helpers/auth.js'
|
import { check_reachable } from '@/helpers/auth.js'
|
||||||
@@ -127,17 +135,29 @@ const { addPopupNotification } = popupNotificationManager
|
|||||||
|
|
||||||
const tauriApiClient = new TauriModrinthClient({
|
const tauriApiClient = new TauriModrinthClient({
|
||||||
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
|
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
|
||||||
|
labrinthBaseUrl: config.labrinthBaseUrl,
|
||||||
|
archonBaseUrl: config.archonBaseUrl,
|
||||||
features: [
|
features: [
|
||||||
|
new NodeAuthFeature({
|
||||||
|
getAuth: () => nodeAuthState.getAuth?.() ?? null,
|
||||||
|
refreshAuth: async () => {
|
||||||
|
if (nodeAuthState.refreshAuth) {
|
||||||
|
await nodeAuthState.refreshAuth()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
new AuthFeature({
|
new AuthFeature({
|
||||||
token: async () => (await getCreds()).session,
|
token: async () => (await getCreds())?.session,
|
||||||
}),
|
}),
|
||||||
new PanelVersionFeature(),
|
new PanelVersionFeature(),
|
||||||
|
new VerboseLoggingFeature(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
provideModrinthClient(tauriApiClient)
|
provideModrinthClient(tauriApiClient)
|
||||||
providePageContext({
|
providePageContext({
|
||||||
hierarchicalSidebarAvailable: ref(true),
|
hierarchicalSidebarAvailable: ref(true),
|
||||||
showAds: ref(false),
|
showAds: ref(false),
|
||||||
|
openExternalUrl: (url) => openUrl(url),
|
||||||
})
|
})
|
||||||
provideModalBehavior({
|
provideModalBehavior({
|
||||||
noblur: computed(() => !themeStore.advancedRendering),
|
noblur: computed(() => !themeStore.advancedRendering),
|
||||||
@@ -395,17 +415,30 @@ const handleClose = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
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) => {
|
router.afterEach((to, from, failure) => {
|
||||||
trackEvent('PageView', {
|
trackEvent('PageView', {
|
||||||
path: to.path,
|
path: to.path,
|
||||||
fromPath: from.path,
|
fromPath: from.path,
|
||||||
failed: failure,
|
failed: failure,
|
||||||
})
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!suspensePending) {
|
||||||
|
loading.stopLoading()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
})
|
})
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const loading = useLoading()
|
|
||||||
loading.setEnabled(false)
|
|
||||||
|
|
||||||
const error = useError()
|
const error = useError()
|
||||||
const errorModal = ref()
|
const errorModal = ref()
|
||||||
@@ -982,13 +1015,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
|
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
|
||||||
<WorldIcon />
|
<WorldIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
<NavButton
|
|
||||||
v-if="themeStore.featureFlags.servers_in_app"
|
|
||||||
v-tooltip.right="'Servers'"
|
|
||||||
to="/hosting/manage"
|
|
||||||
>
|
|
||||||
<ServerIcon />
|
|
||||||
</NavButton>
|
|
||||||
<NavButton
|
<NavButton
|
||||||
v-tooltip.right="'Discover content'"
|
v-tooltip.right="'Discover content'"
|
||||||
to="/browse/modpack"
|
to="/browse/modpack"
|
||||||
@@ -1003,6 +1029,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
<NavButton
|
<NavButton
|
||||||
v-tooltip.right="'Library'"
|
v-tooltip.right="'Library'"
|
||||||
to="/library"
|
to="/library"
|
||||||
|
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
|
||||||
:is-subpage="
|
:is-subpage="
|
||||||
() =>
|
() =>
|
||||||
route.path.startsWith('/instance') ||
|
route.path.startsWith('/instance') ||
|
||||||
@@ -1012,6 +1039,14 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
>
|
>
|
||||||
<LibraryIcon />
|
<LibraryIcon />
|
||||||
</NavButton>
|
</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>
|
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
|
||||||
<suspense>
|
<suspense>
|
||||||
<QuickInstanceSwitcher />
|
<QuickInstanceSwitcher />
|
||||||
@@ -1181,7 +1216,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<div
|
<div
|
||||||
class="loading-indicator-container h-8 fixed z-50"
|
class="loading-indicator-container h-8 fixed z-50 pointer-events-none"
|
||||||
:style="{
|
:style="{
|
||||||
top: 'calc(var(--top-bar-height))',
|
top: 'calc(var(--top-bar-height))',
|
||||||
left: 'calc(var(--left-bar-width))',
|
left: 'calc(var(--left-bar-width))',
|
||||||
@@ -1224,7 +1259,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
</Admonition>
|
</Admonition>
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<template v-if="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>
|
<component :is="Component"></component>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</template>
|
</template>
|
||||||
@@ -1250,11 +1293,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
</div>
|
</div>
|
||||||
<div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
<div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||||
<suspense>
|
<suspense>
|
||||||
<FriendsList
|
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||||
:credentials="credentials"
|
|
||||||
:sign-in="() => signIn()"
|
|
||||||
:refresh-credentials="fetchCredentials"
|
|
||||||
/>
|
|
||||||
</suspense>
|
</suspense>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="news && news.length > 0" class="p-4 pr-1 flex flex-col items-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<I18nDebugPanel />
|
<I18nDebugPanel />
|
||||||
<NotificationPanel has-sidebar />
|
<NotificationPanel :has-sidebar="sidebarVisible" />
|
||||||
<PopupNotificationPanel has-sidebar />
|
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
|
||||||
<ErrorModal ref="errorModal" />
|
<ErrorModal ref="errorModal" />
|
||||||
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
|
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
|
||||||
<ContentInstallModal
|
<ContentInstallModal
|
||||||
|
|||||||
@@ -45,6 +45,14 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--view-width: calc(100% - 5rem);
|
--view-width: calc(100% - 5rem);
|
||||||
--expanded-view-width: calc(100% - 13rem);
|
--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 {
|
body {
|
||||||
@@ -77,12 +85,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-link);
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
&:hover {
|
will-change: filter;
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -174,4 +180,63 @@ img {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type='button'] {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
@import '@modrinth/assets/omorphia.scss';
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
v-if="typeof to === 'string'"
|
v-if="typeof to === 'string'"
|
||||||
:to="to"
|
:to="to"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
:active-class="isSubpage ? '' : undefined"
|
||||||
:class="{
|
:class="{
|
||||||
'router-link-active': isPrimary && isPrimary(route),
|
'router-link-active': isPrimary && isPrimary(route),
|
||||||
'subpage-active': isSubpage && isSubpage(route),
|
'subpage-active': isSubpage && isSubpage(route),
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Button, injectNotificationManager } from '@modrinth/ui'
|
import { Button, injectNotificationManager, ProjectCard } from '@modrinth/ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import SearchCard from '@/components/ui/SearchCard.vue'
|
import { get_project_v3, get_version } from '@/helpers/cache.js'
|
||||||
import { get_project, get_version } from '@/helpers/cache.js'
|
|
||||||
import { get_categories } from '@/helpers/tags.js'
|
|
||||||
import { injectContentInstall } from '@/providers/content-install'
|
import { injectContentInstall } from '@/providers/content-install'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
@@ -14,26 +12,22 @@ const { install: installVersion } = injectContentInstall()
|
|||||||
const confirmModal = ref(null)
|
const confirmModal = ref(null)
|
||||||
const project = ref(null)
|
const project = ref(null)
|
||||||
const version = ref(null)
|
const version = ref(null)
|
||||||
const categories = ref(null)
|
|
||||||
const installing = ref(false)
|
const installing = ref(false)
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
async show(event) {
|
async show(event) {
|
||||||
if (event.event === 'InstallVersion') {
|
if (event.event === 'InstallVersion') {
|
||||||
version.value = await get_version(event.id, 'must_revalidate').catch(handleError)
|
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,
|
handleError,
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
version.value = await get_version(
|
||||||
project.value.versions[project.value.versions.length - 1],
|
project.value.versions[project.value.versions.length - 1],
|
||||||
'must_revalidate',
|
'must_revalidate',
|
||||||
).catch(handleError)
|
).catch(handleError)
|
||||||
}
|
}
|
||||||
categories.value = (await get_categories().catch(handleError)).filter(
|
|
||||||
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
|
|
||||||
)
|
|
||||||
confirmModal.value.show()
|
confirmModal.value.show()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -52,13 +46,22 @@ async function install() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ModalWrapper ref="confirmModal" :header="`Install ${project?.title}`">
|
<ModalWrapper ref="confirmModal" :header="`Install ${project?.name}`">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<SearchCard
|
<ProjectCard
|
||||||
:project="project"
|
: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"
|
class="project-card"
|
||||||
:categories="categories"
|
|
||||||
@open="confirmModal.hide()"
|
|
||||||
/>
|
/>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<div class="markdown-body">
|
<div class="markdown-body">
|
||||||
|
|||||||
@@ -198,45 +198,48 @@ const messages = defineMessages({
|
|||||||
<template>
|
<template>
|
||||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeProfile" />
|
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeProfile" />
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<div class="float-end ml-4 relative group">
|
<div class="float-end ml-10 relative group w-fit">
|
||||||
<OverflowMenu
|
<div class="flex flex-col gap-1">
|
||||||
v-tooltip="formatMessage(messages.editIcon)"
|
<span class="text-lg font-semibold text-contrast">Icon</span>
|
||||||
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
|
<div class="group relative w-fit">
|
||||||
:options="[
|
<OverflowMenu
|
||||||
{
|
v-tooltip="formatMessage(messages.editIcon)"
|
||||||
id: 'select',
|
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
|
||||||
action: () => setIcon(),
|
:options="[
|
||||||
},
|
{
|
||||||
{
|
id: 'select',
|
||||||
id: 'remove',
|
action: () => setIcon(),
|
||||||
color: 'danger',
|
},
|
||||||
action: () => resetIcon(),
|
{
|
||||||
shown: !!icon,
|
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"
|
|
||||||
>
|
>
|
||||||
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
|
<Avatar
|
||||||
</div>
|
: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>
|
</div>
|
||||||
<template #select>
|
</div>
|
||||||
<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) }}
|
{{ formatMessage(messages.name) }}
|
||||||
</label>
|
</label>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
@@ -249,76 +252,82 @@ const messages = defineMessages({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="instance.install_stage == 'installed'">
|
<template v-if="instance.install_stage == 'installed'">
|
||||||
<div>
|
<div class="flex flex-col gap-2.5 mt-6">
|
||||||
<h2
|
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||||
id="duplicate-instance-label"
|
|
||||||
class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"
|
|
||||||
>
|
|
||||||
{{ formatMessage(messages.duplicateInstance) }}
|
{{ formatMessage(messages.duplicateInstance) }}
|
||||||
</h2>
|
</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) }}
|
{{ formatMessage(messages.duplicateInstanceDescription) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
|
aria-labelledby="delete-instance-label"
|
||||||
aria-labelledby="duplicate-instance-label"
|
:disabled="removing"
|
||||||
:disabled="installing"
|
class="w-fit !shadow-none"
|
||||||
@click="duplicateProfile"
|
@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>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</template>
|
<p class="m-0">
|
||||||
<h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
{{ formatMessage(messages.deleteInstanceDescription) }}
|
||||||
{{ formatMessage(messages.libraryGroups) }}
|
</p>
|
||||||
</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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -101,57 +101,57 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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) }}
|
{{ formatMessage(messages.hooks) }}
|
||||||
</h2>
|
</h2>
|
||||||
|
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
|
||||||
<p class="m-0">
|
<p class="m-0">
|
||||||
{{ formatMessage(messages.hooksDescription) }}
|
{{ formatMessage(messages.hooksDescription) }}
|
||||||
</p>
|
</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) }}
|
{{ formatMessage(messages.preLaunch) }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0">
|
|
||||||
{{ formatMessage(messages.preLaunchDescription) }}
|
|
||||||
</p>
|
|
||||||
<StyledInput
|
<StyledInput
|
||||||
id="pre-launch"
|
id="pre-launch"
|
||||||
v-model="hooks.pre_launch"
|
v-model="hooks.pre_launch"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:disabled="!overrideHooks"
|
:disabled="!overrideHooks"
|
||||||
:placeholder="formatMessage(messages.preLaunchEnter)"
|
: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) }}
|
{{ formatMessage(messages.wrapper) }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0">
|
|
||||||
{{ formatMessage(messages.wrapperDescription) }}
|
|
||||||
</p>
|
|
||||||
<StyledInput
|
<StyledInput
|
||||||
id="wrapper"
|
id="wrapper"
|
||||||
v-model="hooks.wrapper"
|
v-model="hooks.wrapper"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:disabled="!overrideHooks"
|
:disabled="!overrideHooks"
|
||||||
:placeholder="formatMessage(messages.wrapperEnter)"
|
: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) }}
|
{{ formatMessage(messages.postExit) }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0">
|
|
||||||
{{ formatMessage(messages.postExitDescription) }}
|
|
||||||
</p>
|
|
||||||
<StyledInput
|
<StyledInput
|
||||||
id="post-exit"
|
id="post-exit"
|
||||||
v-model="hooks.post_exit"
|
v-model="hooks.post_exit"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:disabled="!overrideHooks"
|
:disabled="!overrideHooks"
|
||||||
:placeholder="formatMessage(messages.postExitEnter)"
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -111,10 +111,10 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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) }}
|
{{ formatMessage(messages.javaInstallation) }}
|
||||||
</h2>
|
</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">
|
<template v-if="!overrideJavaInstall">
|
||||||
<div class="flex my-2 items-center gap-2 font-semibold">
|
<div class="flex my-2 items-center gap-2 font-semibold">
|
||||||
<template v-if="javaInstall">
|
<template v-if="javaInstall">
|
||||||
@@ -144,10 +144,10 @@ const messages = defineMessages({
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
|
<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) }}
|
{{ formatMessage(messages.javaMemory) }}
|
||||||
</h2>
|
</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
|
<Slider
|
||||||
id="max-memory"
|
id="max-memory"
|
||||||
v-model="memory.maximum"
|
v-model="memory.maximum"
|
||||||
@@ -159,7 +159,7 @@ const messages = defineMessages({
|
|||||||
:snap-range="512"
|
:snap-range="512"
|
||||||
unit="MB"
|
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) }}
|
{{ formatMessage(messages.javaArguments) }}
|
||||||
</h2>
|
</h2>
|
||||||
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
|
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
|
||||||
@@ -171,10 +171,10 @@ const messages = defineMessages({
|
|||||||
placeholder="Enter java arguments..."
|
placeholder="Enter java arguments..."
|
||||||
wrapper-class="w-full"
|
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) }}
|
{{ formatMessage(messages.javaEnvironmentVariables) }}
|
||||||
</h2>
|
</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
|
<StyledInput
|
||||||
id="env-vars"
|
id="env-vars"
|
||||||
v-model="envVars"
|
v-model="envVars"
|
||||||
|
|||||||
@@ -94,14 +94,14 @@ const messages = defineMessages({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col gap-6">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-model="overrideWindowSettings"
|
v-model="overrideWindowSettings"
|
||||||
:label="formatMessage(messages.customWindowSettings)"
|
:label="formatMessage(messages.customWindowSettings)"
|
||||||
/>
|
/>
|
||||||
<div class="mt-2 flex items-center gap-4 justify-between">
|
<div class="flex items-center gap-4 justify-between">
|
||||||
<div>
|
<div class="flex flex-col gap-1">
|
||||||
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
|
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||||
{{ formatMessage(messages.fullscreen) }}
|
{{ formatMessage(messages.fullscreen) }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0">
|
<p class="m-0">
|
||||||
@@ -120,9 +120,9 @@ const messages = defineMessages({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-4 justify-between">
|
<div class="flex items-center gap-4 justify-between">
|
||||||
<div>
|
<div class="flex flex-col gap-1">
|
||||||
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
|
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||||
{{ formatMessage(messages.width) }}
|
{{ formatMessage(messages.width) }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0">
|
<p class="m-0">
|
||||||
@@ -139,9 +139,9 @@ const messages = defineMessages({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-4 justify-between">
|
<div class="flex items-center gap-4 justify-between">
|
||||||
<div>
|
<div class="flex flex-col gap-1">
|
||||||
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
|
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||||
{{ formatMessage(messages.height) }}
|
{{ formatMessage(messages.height) }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0">
|
<p class="m-0">
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ const messages = defineMessages({
|
|||||||
>
|
>
|
||||||
<ModrinthIcon class="w-6 h-6" />
|
<ModrinthIcon class="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div class="max-w-[200px]">
|
||||||
<p class="m-0">Modrinth App {{ version }}</p>
|
<p class="m-0">Modrinth App {{ version }}</p>
|
||||||
<p class="m-0">
|
<p class="m-0">
|
||||||
<span v-if="osPlatform === 'macos'">macOS</span>
|
<span v-if="osPlatform === 'macos'">macOS</span>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ watch(
|
|||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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>
|
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
|
||||||
|
|
||||||
<ThemeSelector
|
<ThemeSelector
|
||||||
@@ -36,9 +36,9 @@ watch(
|
|||||||
system-theme-color="system"
|
system-theme-color="system"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<p class="m-0 mt-1">
|
||||||
Enables advanced rendering such as blur effects that may cause performance issues without
|
Enables advanced rendering such as blur effects that may cause performance issues without
|
||||||
hardware-accelerated rendering.
|
hardware-accelerated rendering.
|
||||||
@@ -57,48 +57,48 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
||||||
</div>
|
</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>
|
<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>
|
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
|
||||||
</div>
|
</div>
|
||||||
<Combobox
|
<Combobox
|
||||||
id="opening-page"
|
id="opening-page"
|
||||||
v-model="settings.default_page"
|
v-model="settings.default_page"
|
||||||
name="Opening page dropdown"
|
name="Opening page dropdown"
|
||||||
class="w-40"
|
class="max-w-40"
|
||||||
:options="['Home', 'Library'].map((v) => ({ value: v, label: v }))"
|
:options="['Home', 'Library'].map((v) => ({ value: v, label: v }))"
|
||||||
:display-value="settings.default_page ?? 'Select an option'"
|
:display-value="settings.default_page ?? 'Select an option'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
@@ -113,9 +113,9 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
|
|||||||
@@ -52,127 +52,135 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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">
|
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
|
<div class="flex items-center justify-between gap-4">
|
||||||
</div>
|
<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">
|
<StyledInput
|
||||||
<div>
|
id="width"
|
||||||
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3>
|
v-model="settings.game_resolution[0]"
|
||||||
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
|
:disabled="settings.force_fullscreen"
|
||||||
The width of the game window when launched.
|
autocomplete="off"
|
||||||
</p>
|
type="number"
|
||||||
|
placeholder="Enter width..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StyledInput
|
<div class="flex items-center justify-between gap-4">
|
||||||
id="width"
|
<div class="flex flex-col gap-1">
|
||||||
v-model="settings.game_resolution[0]"
|
<h3 class="m-0 text-lg font-semibold text-contrast">Height</h3>
|
||||||
:disabled="settings.force_fullscreen"
|
<p class="m-0 leading-tight">The height of the game window when launched.</p>
|
||||||
autocomplete="off"
|
</div>
|
||||||
type="number"
|
|
||||||
placeholder="Enter width..."
|
<StyledInput
|
||||||
/>
|
id="height"
|
||||||
|
v-model="settings.game_resolution[1]"
|
||||||
|
:disabled="settings.force_fullscreen"
|
||||||
|
autocomplete="off"
|
||||||
|
type="number"
|
||||||
|
placeholder="Enter height..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
<hr class="my-6 bg-button-border border-none h-[1px]" />
|
||||||
<div>
|
|
||||||
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3>
|
<div class="flex flex-col gap-6">
|
||||||
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
|
<div class="flex flex-col gap-2.5">
|
||||||
The height of the game window when launched.
|
<h2 class="m-0 text-lg font-semibold text-contrast">Memory allocated</h2>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<StyledInput
|
<div class="flex flex-col gap-2.5">
|
||||||
id="height"
|
<h2 class="m-0 text-lg font-semibold text-contrast">Java arguments</h2>
|
||||||
v-model="settings.game_resolution[1]"
|
<StyledInput
|
||||||
:disabled="settings.force_fullscreen"
|
id="java-args"
|
||||||
autocomplete="off"
|
v-model="settings.launchArgs"
|
||||||
type="number"
|
autocomplete="off"
|
||||||
placeholder="Enter height..."
|
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>
|
</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>
|
<div class="flex flex-col gap-6">
|
||||||
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
|
<div class="flex flex-col gap-2.5">
|
||||||
<Slider
|
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
|
||||||
id="max-memory"
|
<StyledInput
|
||||||
v-model="settings.memory.maximum"
|
id="pre-launch"
|
||||||
:min="512"
|
v-model="settings.hooks.pre_launch"
|
||||||
:max="maxMemory"
|
autocomplete="off"
|
||||||
:step="64"
|
type="text"
|
||||||
:snap-points="snapPoints"
|
placeholder="Enter pre-launch command..."
|
||||||
:snap-range="512"
|
wrapper-class="w-full"
|
||||||
unit="MB"
|
/>
|
||||||
/>
|
<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>
|
<div class="flex flex-col gap-2.5">
|
||||||
<StyledInput
|
<h3 class="m-0 text-lg font-semibold text-contrast">Wrapper hook</h3>
|
||||||
id="java-args"
|
<StyledInput
|
||||||
v-model="settings.launchArgs"
|
id="wrapper"
|
||||||
autocomplete="off"
|
v-model="settings.hooks.wrapper"
|
||||||
type="text"
|
autocomplete="off"
|
||||||
placeholder="Enter java arguments..."
|
type="text"
|
||||||
wrapper-class="w-full"
|
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>
|
<div class="flex flex-col gap-2.5">
|
||||||
<StyledInput
|
<h3 class="m-0 text-lg font-semibold text-contrast">Post exit hook</h3>
|
||||||
id="env-vars"
|
<StyledInput
|
||||||
v-model="settings.envVars"
|
id="post-exit"
|
||||||
autocomplete="off"
|
v-model="settings.hooks.post_exit"
|
||||||
type="text"
|
autocomplete="off"
|
||||||
placeholder="Enter environmental variables..."
|
type="text"
|
||||||
wrapper-class="w-full"
|
placeholder="Enter post-exit command..."
|
||||||
/>
|
wrapper-class="w-full"
|
||||||
|
/>
|
||||||
<hr class="mt-4 bg-button-border border-none h-[1px]" />
|
<p class="m-0 leading-tight">Ran after the game closes.</p>
|
||||||
|
</div>
|
||||||
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2>
|
</div>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -25,26 +25,28 @@ watch(
|
|||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
|
<div class="flex flex-col gap-2.5 min-w-[600px]">
|
||||||
<div>
|
<div v-for="option in options" :key="option" class="flex items-center justify-between">
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
|
<div>
|
||||||
{{ option.replaceAll('_', ' ') }}
|
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
|
||||||
</h2>
|
{{ option.replaceAll('_', ' ') }}
|
||||||
</div>
|
</h2>
|
||||||
<div class="flex items-center gap-2">
|
</div>
|
||||||
<ButtonStyled type="transparent">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<ButtonStyled type="transparent">
|
||||||
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
|
<button
|
||||||
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
|
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
|
||||||
>
|
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
|
||||||
Reset to default
|
>
|
||||||
</button>
|
Reset to default
|
||||||
</ButtonStyled>
|
</button>
|
||||||
<Toggle
|
</ButtonStyled>
|
||||||
id="advanced-rendering"
|
<Toggle
|
||||||
:model-value="themeStore.getFeatureFlag(option)"
|
id="advanced-rendering"
|
||||||
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
|
:model-value="themeStore.getFeatureFlag(option)"
|
||||||
/>
|
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,15 +21,21 @@ async function updateJavaVersion(version) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div v-for="(javaVersion, index) in [25, 21, 17, 8]" :key="`java-${javaVersion}`">
|
<div class="flex flex-col gap-6">
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
|
<div
|
||||||
Java {{ javaVersion }} location
|
v-for="(javaVersion, index) in [25, 21, 17, 8]"
|
||||||
</h2>
|
:key="`java-${javaVersion}`"
|
||||||
<JavaSelector
|
class="flex flex-col gap-2.5"
|
||||||
:id="'java-selector-' + javaVersion"
|
>
|
||||||
v-model="javaVersions[javaVersion]"
|
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-4': index !== 0 }">
|
||||||
:version="javaVersion"
|
Java {{ javaVersion }} location
|
||||||
@update:model-value="updateJavaVersion"
|
</h2>
|
||||||
/>
|
<JavaSelector
|
||||||
|
:id="'java-selector-' + javaVersion"
|
||||||
|
v-model="javaVersions[javaVersion]"
|
||||||
|
:version="javaVersion"
|
||||||
|
@update:model-value="updateJavaVersion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async function onLocaleChange(newLocale: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<Admonition type="warning" class="mt-2 mb-4">
|
||||||
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
|
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2>
|
<h2 class="m-0 text-lg font-semibold text-contrast">Personalized ads</h2>
|
||||||
<p class="m-0 text-sm">
|
<p class="m-0 mt-1 text-sm">
|
||||||
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
|
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.
|
option, you opt out and ads will no longer be shown based on your interests.
|
||||||
</p>
|
</p>
|
||||||
@@ -36,8 +36,8 @@ watch(
|
|||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between gap-4">
|
<div class="mt-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2>
|
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
|
||||||
<p class="m-0 text-sm">
|
<p class="m-0 mt-1 text-sm">
|
||||||
Modrinth collects anonymized analytics and usage data to improve our user experience and
|
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
|
customize your experience. By disabling this option, you opt out and your data will no
|
||||||
longer be collected.
|
longer be collected.
|
||||||
@@ -48,8 +48,8 @@ watch(
|
|||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between gap-4">
|
<div class="mt-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2>
|
<h2 class="m-0 text-lg font-semibold text-contrast">Discord RPC</h2>
|
||||||
<p class="m-0 text-sm">
|
<p class="m-0 mt-1 text-sm">
|
||||||
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
|
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.
|
longer show up as a game or app you are using on your Discord profile.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -62,67 +62,77 @@ async function findLauncherDir() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2>
|
<div class="flex flex-col gap-6">
|
||||||
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
|
<div class="flex flex-col gap-2.5">
|
||||||
The directory where the launcher stores all of its files. Changes will be applied after
|
<h2 class="m-0 text-lg font-semibold text-contrast">App directory</h2>
|
||||||
restarting the launcher.
|
<StyledInput
|
||||||
</p>
|
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">
|
<div class="flex flex-col gap-2.5">
|
||||||
<StyledInput
|
<ConfirmModalWrapper
|
||||||
id="appDir"
|
ref="purgeCacheConfirmModal"
|
||||||
v-model="settings.custom_dir"
|
title="Are you sure you want to purge the cache?"
|
||||||
:icon="BoxIcon"
|
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
|
||||||
type="text"
|
:has-to-type="false"
|
||||||
wrapper-class="w-full"
|
proceed-label="Purge cache"
|
||||||
>
|
:show-ad-on-close="false"
|
||||||
<template #right>
|
@proceed="purgeCache"
|
||||||
<Button class="r-btn" @click="findLauncherDir">
|
/>
|
||||||
<FolderSearchIcon />
|
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
|
||||||
</Button>
|
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
|
||||||
</template>
|
<TrashIcon />
|
||||||
</StyledInput>
|
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>
|
||||||
|
|
||||||
<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>
|
</template>
|
||||||
|
|||||||
92
apps/app-frontend/src/composables/useInstanceConsole.ts
Normal file
92
apps/app-frontend/src/composables/useInstanceConsole.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/app-frontend/src/config.ts
Normal file
18
apps/app-frontend/src/config.ts
Normal 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,
|
||||||
|
}
|
||||||
@@ -97,3 +97,23 @@ export async function warning_listener(callback) {
|
|||||||
export async function friend_listener(callback) {
|
export async function friend_listener(callback) {
|
||||||
return await listen('friend', (event) => callback(event.payload))
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,3 +63,13 @@ export async function delete_logs(profilePath) {
|
|||||||
export async function get_latest_log_cursor(profilePath, cursor) {
|
export async function get_latest_log_cursor(profilePath, cursor) {
|
||||||
return await invoke('plugin:logs|logs_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
26
apps/app-frontend/src/pages/Servers.vue
Normal file
26
apps/app-frontend/src/pages/Servers.vue
Normal 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>
|
||||||
9
apps/app-frontend/src/pages/hosting/manage/Backups.vue
Normal file
9
apps/app-frontend/src/pages/hosting/manage/Backups.vue
Normal 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>
|
||||||
7
apps/app-frontend/src/pages/hosting/manage/Content.vue
Normal file
7
apps/app-frontend/src/pages/hosting/manage/Content.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ServersManageContentPage } from '@modrinth/ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ServersManageContentPage />
|
||||||
|
</template>
|
||||||
7
apps/app-frontend/src/pages/hosting/manage/Files.vue
Normal file
7
apps/app-frontend/src/pages/hosting/manage/Files.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ServersManageFilesPage } from '@modrinth/ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ServersManageFilesPage />
|
||||||
|
</template>
|
||||||
113
apps/app-frontend/src/pages/hosting/manage/Index.vue
Normal file
113
apps/app-frontend/src/pages/hosting/manage/Index.vue
Normal 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>
|
||||||
7
apps/app-frontend/src/pages/hosting/manage/Overview.vue
Normal file
7
apps/app-frontend/src/pages/hosting/manage/Overview.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ServersManageOverviewPage } from '@modrinth/ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ServersManageOverviewPage />
|
||||||
|
</template>
|
||||||
7
apps/app-frontend/src/pages/hosting/manage/index.js
Normal file
7
apps/app-frontend/src/pages/hosting/manage/index.js
Normal 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 }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Browse from './Browse.vue'
|
import Browse from './Browse.vue'
|
||||||
import Index from './Index.vue'
|
import Index from './Index.vue'
|
||||||
|
import Servers from './Servers.vue'
|
||||||
import Skins from './Skins.vue'
|
import Skins from './Skins.vue'
|
||||||
import Worlds from './Worlds.vue'
|
import Worlds from './Worlds.vue'
|
||||||
|
|
||||||
export { Browse, Index, Skins, Worlds }
|
export { Browse, Index, Servers, Skins, Worlds }
|
||||||
|
|||||||
@@ -114,9 +114,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||||
<button @click="stopInstance('InstancePage')">
|
<button :disabled="stopping" @click="stopInstance('InstancePage')">
|
||||||
<StopCircleIcon />
|
<StopCircleIcon />
|
||||||
Stop
|
{{ stopping ? 'Stopping...' : 'Stop' }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
color="brand"
|
color="brand"
|
||||||
size="large"
|
size="large"
|
||||||
>
|
>
|
||||||
<button disabled>Loading...</button>
|
<button disabled>Starting...</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled circular size="large">
|
<ButtonStyled circular size="large">
|
||||||
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
|
<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 ExportModal from '@/components/ui/ExportModal.vue'
|
||||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||||
|
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { get_project_v3 } from '@/helpers/cache.js'
|
import { get_project_v3 } from '@/helpers/cache.js'
|
||||||
import { process_listener, profile_listener } from '@/helpers/events'
|
import { process_listener, profile_listener } from '@/helpers/events'
|
||||||
@@ -345,6 +346,7 @@ window.addEventListener('online', () => {
|
|||||||
const instance = ref<GameInstance>()
|
const instance = ref<GameInstance>()
|
||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const stopping = ref(false)
|
||||||
const exportModal = ref<InstanceType<typeof ExportModal>>()
|
const exportModal = ref<InstanceType<typeof ExportModal>>()
|
||||||
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
|
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
|
||||||
|
|
||||||
@@ -494,8 +496,10 @@ const startInstance = async (context: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stopInstance = async (context: string) => {
|
const stopInstance = async (context: string) => {
|
||||||
playing.value = false
|
stopping.value = true
|
||||||
await kill(route.params.id as string).catch(handleError)
|
await kill(route.params.id as string).catch(handleError)
|
||||||
|
stopping.value = false
|
||||||
|
playing.value = false
|
||||||
|
|
||||||
if (!instance.value) return
|
if (!instance.value) return
|
||||||
trackEvent('InstanceStop', {
|
trackEvent('InstanceStop', {
|
||||||
@@ -644,6 +648,11 @@ const timePlayedHumanized = computed(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unlistenProcesses()
|
unlistenProcesses()
|
||||||
unlistenProfiles()
|
unlistenProfiles()
|
||||||
|
const profilePath = route.params.id
|
||||||
|
if (profilePath) {
|
||||||
|
const { destroy } = useInstanceConsole(profilePath)
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,127 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<Card class="log-card">
|
<div class="flex flex-col gap-4 h-full">
|
||||||
<div class="button-row">
|
<ConsolePageLayout />
|
||||||
<DropdownSelect
|
</div>
|
||||||
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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
|
||||||
|
|
||||||
import { CheckIcon, ClipboardCopyIcon, SearchIcon, ShareIcon, TrashIcon } from '@modrinth/assets'
|
|
||||||
import {
|
import {
|
||||||
Button,
|
ConsolePageLayout,
|
||||||
Card,
|
injectModrinthClient,
|
||||||
Checkbox,
|
|
||||||
DropdownSelect,
|
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
StyledInput,
|
provideConsoleManager,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import dayjs from 'dayjs'
|
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
|
||||||
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 { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
|
||||||
|
|
||||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||||
import { process_listener } from '@/helpers/events.js'
|
import { log_listener, process_listener } from '@/helpers/events.js'
|
||||||
import {
|
import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js'
|
||||||
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)
|
|
||||||
|
|
||||||
|
const client = injectModrinthClient()
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -158,414 +55,179 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentLiveLog = ref(null)
|
const profilePathId = computed(() => route.params.id)
|
||||||
const currentLiveLogCursor = ref(0)
|
const {
|
||||||
const emptyText = ['No live game detected.', 'Start your game to proceed.']
|
liveConsole,
|
||||||
|
historicalConsole,
|
||||||
|
hydrate,
|
||||||
|
getHistoricalLogs,
|
||||||
|
getHistoricalContent,
|
||||||
|
invalidate,
|
||||||
|
} = useInstanceConsole(profilePathId.value)
|
||||||
|
|
||||||
const logs = ref([])
|
await hydrate()
|
||||||
await setLogs()
|
|
||||||
|
|
||||||
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 selectedLogIndex = ref(0)
|
||||||
const copied = ref(false)
|
const isLive = computed(() => selectedLogIndex.value === 0)
|
||||||
const logContainer = ref(null)
|
|
||||||
const interval = ref(null)
|
|
||||||
const userScrolled = ref(false)
|
|
||||||
const isAutoScrolling = ref(false)
|
|
||||||
const shareModal = ref(null)
|
|
||||||
|
|
||||||
const levels = ['Comment', 'Error', 'Warn', 'Info', 'Debug', 'Trace']
|
const filteredLogs = computed(() =>
|
||||||
const levelFilters = ref({})
|
props.playing ? logs.value.filter((l) => l.live || l.name !== 'latest.log') : logs.value,
|
||||||
levels.forEach((level) => {
|
)
|
||||||
levelFilters.value[level.toLowerCase()] = true
|
|
||||||
|
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) {
|
const crashAnalysis = ref(null)
|
||||||
if (!processedLine.level) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!levelFilters.value[processedLine.level.toLowerCase()]) {
|
async function analyseForCrash() {
|
||||||
return false
|
const lines = liveConsole.output.value
|
||||||
}
|
if (lines.length === 0) return
|
||||||
if (searchFilter.value !== '') {
|
|
||||||
if (!processedLine.text.toLowerCase().includes(searchFilter.value.toLowerCase())) {
|
const content = lines.map((l) => l.text).join('\n')
|
||||||
return false
|
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)
|
const selectedLog = computed(() => filteredLogs.value[selectedLogIndex.value])
|
||||||
// In addition, splits each line by \n. Each split line is given the same properties as the original line
|
|
||||||
const displayProcessedLogs = computed(() => {
|
const deleteDisabled = computed(() => {
|
||||||
return processedLogs.value.filter((l) => shouldDisplay(l))
|
const log = selectedLog.value
|
||||||
|
if (!log || log.live) return true
|
||||||
|
return log.filename === 'latest.log' && props.playing
|
||||||
})
|
})
|
||||||
|
|
||||||
const processedLogs = computed(() => {
|
async function deleteSelectedLog() {
|
||||||
// split based on newline and timestamp lookahead
|
const log = selectedLog.value
|
||||||
// (not just newline because of multiline messages)
|
if (!log || log.live) return
|
||||||
const splitPattern = /\n(?=(?:#|\[\d\d:\d\d:\d\d\]))/
|
await delete_logs_by_filename(props.instance.path, log.log_type, log.filename)
|
||||||
|
invalidate()
|
||||||
const lines = logs.value[selectedLogIndex.value]?.stdout.split(splitPattern) || []
|
const freshLogs = await getHistoricalLogs(props.instance.path)
|
||||||
const processed = []
|
logs.value = buildLogList(freshLogs)
|
||||||
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 {
|
|
||||||
selectedLogIndex.value = 0
|
selectedLogIndex.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteLog = async () => {
|
provideConsoleManager({
|
||||||
if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) {
|
logLines,
|
||||||
const deleteIndex = selectedLogIndex.value
|
logSources,
|
||||||
selectedLogIndex.value = deleteIndex - 1
|
activeLogSourceIndex: selectedLogIndex,
|
||||||
await delete_logs_by_filename(
|
showCommandInput: false,
|
||||||
props.instance.path,
|
loading: ref(false),
|
||||||
logs.value[deleteIndex].log_type,
|
onClear: () => {
|
||||||
logs.value[deleteIndex].filename,
|
activeConsole.value.clear()
|
||||||
).catch(handleError)
|
},
|
||||||
await setLogs()
|
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 () => {
|
const unlistenLog = await log_listener((payload) => {
|
||||||
currentLiveLog.value = ''
|
if (payload.profile_path_id !== profilePathId.value) return
|
||||||
// does not reset cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLineLevel = (text, level) => {
|
if (payload.type === 'log4j') {
|
||||||
if ((text.includes('/INFO') || text.includes('[System] [CHAT]')) && level === 'info') {
|
liveConsole.addLog4jEvent(payload)
|
||||||
return true
|
} 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) => {
|
const unlistenProcesses = await process_listener(async (e) => {
|
||||||
|
if (e.profile_path_id !== profilePathId.value) return
|
||||||
if (e.event === 'launched') {
|
if (e.event === 'launched') {
|
||||||
currentLiveLog.value = ''
|
liveConsole.clear()
|
||||||
currentLiveLogCursor.value = 0
|
invalidate()
|
||||||
selectedLogIndex.value = 0
|
selectedLogIndex.value = 0
|
||||||
}
|
}
|
||||||
if (e.event === 'finished') {
|
if (e.event === 'finished') {
|
||||||
currentLiveLog.value = ''
|
invalidate()
|
||||||
currentLiveLogCursor.value = 0
|
const freshLogs = await getHistoricalLogs(props.instance.path)
|
||||||
userScrolled.value = false
|
logs.value = buildLogList(freshLogs)
|
||||||
await setLogs()
|
void analyseForCrash()
|
||||||
selectedLogIndex.value = 1
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
logContainer.value.$el.addEventListener('scroll', handleUserScroll)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
logContainer.value.$el.removeEventListener('scroll', handleUserScroll)
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearInterval(interval.value)
|
unlistenLog()
|
||||||
unlistenProcesses()
|
unlistenProcesses()
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
|
||||||
|
|||||||
@@ -357,9 +357,7 @@ const MAX_LINUX_REFRESHES = 3
|
|||||||
const isLinux = platform() === 'linux'
|
const isLinux = platform() === 'linux'
|
||||||
const linuxRefreshCount = ref(0)
|
const linuxRefreshCount = ref(0)
|
||||||
|
|
||||||
const protocolVersion = ref<ProtocolVersion | null>(
|
const protocolVersion = ref<ProtocolVersion | null>(null)
|
||||||
await get_profile_protocol_version(instance.value.path),
|
|
||||||
)
|
|
||||||
const managedServerName = ref<string | null>(null)
|
const managedServerName = ref<string | null>(null)
|
||||||
const managedServerAddress = ref<string | null>(null)
|
const managedServerAddress = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -424,22 +422,27 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
const [unlistenProfile, , resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
|
||||||
if (e.profile_path_id !== instance.value.path) return
|
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 (e.event === 'servers_updated') {
|
||||||
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
|
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
|
||||||
if (isLinux) linuxRefreshCount.value++
|
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) {
|
async function refreshServer(address: string) {
|
||||||
if (!serverData.value[address]) {
|
if (!serverData.value[address]) {
|
||||||
@@ -589,7 +592,7 @@ function worldsMatch(world: World, other: World | undefined) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
const gameVersions = ref<GameVersion[]>(resolvedGameVersions)
|
||||||
const supportsServerQuickPlay = computed(() =>
|
const supportsServerQuickPlay = computed(() =>
|
||||||
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { type AuthProvider, provideAuth } from '@modrinth/ui'
|
import { type AuthProvider, provideAuth } from '@modrinth/ui'
|
||||||
import { type Ref, ref, watchEffect } from 'vue'
|
import { computed, type Ref, ref, watchEffect } from 'vue'
|
||||||
|
|
||||||
type AppCredentials = {
|
type AppCredentials = {
|
||||||
session?: string | null
|
session?: string | null
|
||||||
@@ -13,10 +13,12 @@ export function setupAuthProvider(
|
|||||||
) {
|
) {
|
||||||
const sessionToken = ref<string | null>(null)
|
const sessionToken = ref<string | null>(null)
|
||||||
const user = ref<Labrinth.Users.v2.User | null>(null)
|
const user = ref<Labrinth.Users.v2.User | null>(null)
|
||||||
|
const isReady = computed(() => credentials.value !== undefined)
|
||||||
|
|
||||||
const authProvider: AuthProvider = {
|
const authProvider: AuthProvider = {
|
||||||
session_token: sessionToken,
|
session_token: sessionToken,
|
||||||
user,
|
user,
|
||||||
|
isReady,
|
||||||
requestSignIn,
|
requestSignIn,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
393
apps/app-frontend/src/providers/setup/server-install-content.ts
Normal file
393
apps/app-frontend/src/providers/setup/server-install-content.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ServersManagePageIndex } from '@modrinth/ui'
|
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
import * as Pages from '@/pages'
|
import * as Pages from '@/pages'
|
||||||
|
import * as Hosting from '@/pages/hosting/manage'
|
||||||
import * as Instance from '@/pages/instance'
|
import * as Instance from '@/pages/instance'
|
||||||
import * as Library from '@/pages/library'
|
import * as Library from '@/pages/library'
|
||||||
import * as Project from '@/pages/project'
|
import * as Project from '@/pages/project'
|
||||||
@@ -31,11 +31,50 @@ export default new createRouter({
|
|||||||
{
|
{
|
||||||
path: '/hosting/manage/',
|
path: '/hosting/manage/',
|
||||||
name: 'Servers',
|
name: 'Servers',
|
||||||
component: ServersManagePageIndex,
|
component: Pages.Servers,
|
||||||
meta: {
|
meta: {
|
||||||
breadcrumb: [{ name: 'Servers' }],
|
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',
|
path: '/browse/:projectType',
|
||||||
name: 'Discover content',
|
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',
|
path: '/project/:id',
|
||||||
name: 'Project',
|
name: 'Project',
|
||||||
@@ -202,7 +248,8 @@ export default new createRouter({
|
|||||||
],
|
],
|
||||||
linkActiveClass: 'router-link-active',
|
linkActiveClass: 'router-link-active',
|
||||||
linkExactActiveClass: 'router-link-exact-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)
|
// 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)
|
document.querySelector('.app-viewport')?.scrollTo(0, 0)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export const DEFAULT_FEATURE_FLAGS = {
|
|||||||
page_path: false,
|
page_path: false,
|
||||||
worlds_tab: false,
|
worlds_tab: false,
|
||||||
worlds_in_home: true,
|
worlds_in_home: true,
|
||||||
servers_in_app: false,
|
|
||||||
server_project_qa: false,
|
server_project_qa: false,
|
||||||
i18n_debug: false,
|
i18n_debug: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { existsSync, readFileSync } from 'fs'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import svgLoader from 'vite-svg-loader'
|
import svgLoader from 'vite-svg-loader'
|
||||||
@@ -6,6 +7,23 @@ import svgLoader from 'vite-svg-loader'
|
|||||||
import tauriConf from '../app/tauri.conf.json'
|
import tauriConf from '../app/tauri.conf.json'
|
||||||
|
|
||||||
const projectRootDir = resolve(__dirname)
|
const projectRootDir = resolve(__dirname)
|
||||||
|
const appLibEnvDir = resolve(projectRootDir, '../../packages/app-lib')
|
||||||
|
|
||||||
|
// Load .env from app-lib manually instead of using Vite's envDir, which would auto-load .env.local and override values
|
||||||
|
const envFilePath = resolve(appLibEnvDir, '.env')
|
||||||
|
if (existsSync(envFilePath)) {
|
||||||
|
for (const line of readFileSync(envFilePath, 'utf-8').split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
const eqIndex = trimmed.indexOf('=')
|
||||||
|
if (eqIndex === -1) continue
|
||||||
|
const key = trimmed.slice(0, eqIndex)
|
||||||
|
const value = trimmed.slice(eqIndex + 1)
|
||||||
|
if (!(key in process.env)) {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -68,7 +86,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
// to make use of `TAURI_ENV_DEBUG` and other env variables
|
// to make use of `TAURI_ENV_DEBUG` and other env variables
|
||||||
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
|
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
|
||||||
envPrefix: ['VITE_', 'TAURI_'],
|
envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_'],
|
||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
onwarn(warning, defaultHandler) {
|
onwarn(warning, defaultHandler) {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ fn main() {
|
|||||||
"logs_delete_logs",
|
"logs_delete_logs",
|
||||||
"logs_delete_logs_by_filename",
|
"logs_delete_logs_by_filename",
|
||||||
"logs_get_latest_log_cursor",
|
"logs_get_latest_log_cursor",
|
||||||
|
"logs_get_live_log_buffer",
|
||||||
|
"logs_clear_live_log_buffer",
|
||||||
])
|
])
|
||||||
.default_permission(
|
.default_permission(
|
||||||
DefaultPermissionRule::AllowAllCommands,
|
DefaultPermissionRule::AllowAllCommands,
|
||||||
|
|||||||
@@ -22,7 +22,12 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
"allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
|
"allow": [
|
||||||
|
{ "url": "https://modrinth.com/*" },
|
||||||
|
{ "url": "https://*.modrinth.com/*" },
|
||||||
|
{ "url": "https://*.nodes.modrinth.com/*" },
|
||||||
|
{ "url": "https://api.mclo.gs/*" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
logs_delete_logs,
|
logs_delete_logs,
|
||||||
logs_delete_logs_by_filename,
|
logs_delete_logs_by_filename,
|
||||||
logs_get_latest_log_cursor,
|
logs_get_latest_log_cursor,
|
||||||
|
logs_get_live_log_buffer,
|
||||||
|
logs_clear_live_log_buffer,
|
||||||
])
|
])
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -83,3 +85,18 @@ pub async fn logs_get_latest_log_cursor(
|
|||||||
) -> Result<LatestLogCursor> {
|
) -> Result<LatestLogCursor> {
|
||||||
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
|
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all buffered live log lines for a profile
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_get_live_log_buffer(
|
||||||
|
profile_path: &str,
|
||||||
|
) -> Result<CensoredString> {
|
||||||
|
Ok(logs::get_live_log_buffer(profile_path).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the live log buffer for a profile
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_clear_live_log_buffer(profile_path: &str) -> Result<()> {
|
||||||
|
logs::clear_live_log_buffer(profile_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
use native_dialog::{DialogBuilder, MessageLevel};
|
use native_dialog::{DialogBuilder, MessageLevel};
|
||||||
use std::env;
|
use std::env;
|
||||||
use tauri::{Listener, Manager};
|
use tauri::{Listener, Manager};
|
||||||
|
use tauri_plugin_fs::FsExt;
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
@@ -35,6 +36,8 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
|||||||
.allow_directory(state.directories.caches_dir(), true)?;
|
.allow_directory(state.directories.caches_dir(), true)?;
|
||||||
app.asset_protocol_scope()
|
app.asset_protocol_scope()
|
||||||
.allow_directory(state.directories.caches_dir().join("icons"), true)?;
|
.allow_directory(state.directories.caches_dir().join("icons"), true)?;
|
||||||
|
app.fs_scope()
|
||||||
|
.allow_directory(state.directories.profiles_dir(), true)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,12 +87,12 @@
|
|||||||
"capabilities": ["ads", "core", "plugins"],
|
"capabilities": ["ads", "core", "plugins"],
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": "'self' customprotocol: asset:",
|
"default-src": "'self' customprotocol: asset:",
|
||||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net 'self' data: blob:",
|
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net 'self' data: blob:",
|
||||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
"script-src": "https://*.posthog.com https://tally.so/widgets/embed.js 'self'",
|
"script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://tally.so/widgets/embed.js 'self'",
|
||||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ 'self'",
|
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com 'self'",
|
||||||
"media-src": "https://*.githubusercontent.com"
|
"media-src": "https://*.githubusercontent.com"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,6 +841,23 @@ button {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input-wrapper__after {
|
||||||
|
display: flex;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0.5rem 1rem 0.5rem 0;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
min-height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: fit-content;
|
||||||
|
align-items: center;
|
||||||
|
filter: grayscale(50%);
|
||||||
|
opacity: 0.5;
|
||||||
|
box-shadow: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
|
|||||||
@@ -456,9 +456,9 @@ kbd {
|
|||||||
font-size: 0.85em !important;
|
font-size: 0.85em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@import '~/assets/styles/layout.scss';
|
@import './layout.scss';
|
||||||
@import '~/assets/styles/utils.scss';
|
@import './utils.scss';
|
||||||
@import '~/assets/styles/components.scss';
|
@import './components.scss';
|
||||||
|
|
||||||
// OMORPHIA FIXES
|
// OMORPHIA FIXES
|
||||||
.card {
|
.card {
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
|
||||||
<div
|
|
||||||
v-for="loader in vanillaLoaders"
|
|
||||||
:key="loader.name"
|
|
||||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
|
||||||
>
|
|
||||||
<LoaderSelectorCard
|
|
||||||
:loader="loader"
|
|
||||||
:is-current="isCurrentLoader(loader.name)"
|
|
||||||
:loader-version="data.loader_version"
|
|
||||||
:current-loader="data.loader"
|
|
||||||
:is-installing="isInstalling"
|
|
||||||
@select="selectLoader"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Mod loaders</h2>
|
|
||||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
|
||||||
<div
|
|
||||||
v-for="loader in modLoaders"
|
|
||||||
:key="loader.name"
|
|
||||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
|
||||||
>
|
|
||||||
<LoaderSelectorCard
|
|
||||||
:loader="loader"
|
|
||||||
:is-current="isCurrentLoader(loader.name)"
|
|
||||||
:loader-version="data.loader_version"
|
|
||||||
:current-loader="data.loader"
|
|
||||||
:is-installing="isInstalling"
|
|
||||||
@select="selectLoader"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Plugin loaders</h2>
|
|
||||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
|
||||||
<div
|
|
||||||
v-for="loader in pluginLoaders"
|
|
||||||
:key="loader.name"
|
|
||||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
|
||||||
>
|
|
||||||
<LoaderSelectorCard
|
|
||||||
:loader="loader"
|
|
||||||
:is-current="isCurrentLoader(loader.name)"
|
|
||||||
:loader-version="data.loader_version"
|
|
||||||
:current-loader="data.loader"
|
|
||||||
:is-installing="isInstalling"
|
|
||||||
@select="selectLoader"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import LoaderSelectorCard from './LoaderSelectorCard.vue'
|
|
||||||
const props = defineProps<{
|
|
||||||
data: {
|
|
||||||
loader: string | null
|
|
||||||
loader_version: string | null
|
|
||||||
}
|
|
||||||
ignoreCurrentInstallation?: boolean
|
|
||||||
isInstalling?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'selectLoader', loader: string): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const vanillaLoaders = [{ name: 'Vanilla' as const, displayName: 'Vanilla' }]
|
|
||||||
|
|
||||||
const modLoaders = [
|
|
||||||
{ name: 'Fabric' as const, displayName: 'Fabric' },
|
|
||||||
{ name: 'Quilt' as const, displayName: 'Quilt' },
|
|
||||||
{ name: 'Forge' as const, displayName: 'Forge' },
|
|
||||||
{ name: 'NeoForge' as const, displayName: 'NeoForge' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const pluginLoaders = [
|
|
||||||
{ name: 'Paper' as const, displayName: 'Paper' },
|
|
||||||
{ name: 'Purpur' as const, displayName: 'Purpur' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const isCurrentLoader = (loaderName: string) => {
|
|
||||||
return props.data.loader?.toLowerCase() === loaderName.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectLoader = (loader: string) => {
|
|
||||||
emit('selectLoader', loader)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex w-full items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div
|
|
||||||
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
|
||||||
:class="isCurrentLoader ? '[&&]:bg-bg-green' : ''"
|
|
||||||
>
|
|
||||||
<LoaderIcon
|
|
||||||
:loader="loader.name"
|
|
||||||
class="size-6"
|
|
||||||
:class="isCurrentLoader ? 'text-brand' : ''"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-0.5">
|
|
||||||
<div class="flex flex-row items-center gap-2">
|
|
||||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
|
||||||
{{ loader.displayName }}
|
|
||||||
</h1>
|
|
||||||
<span
|
|
||||||
v-if="isCurrentLoader"
|
|
||||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
|
||||||
>
|
|
||||||
<CheckIcon class="h-4 w-4" /> Current
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">{{ loaderVersion }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button :disabled="isInstalling" @click="onSelect">
|
|
||||||
<DownloadIcon class="h-5 w-5" /> {{ isCurrentLoader ? 'Reinstall' : 'Install' }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { CheckIcon, DownloadIcon } from '@modrinth/assets'
|
|
||||||
import { ButtonStyled, LoaderIcon } from '@modrinth/ui'
|
|
||||||
|
|
||||||
interface LoaderInfo {
|
|
||||||
name: 'Vanilla' | 'Fabric' | 'Forge' | 'Quilt' | 'Paper' | 'NeoForge' | 'Purpur'
|
|
||||||
displayName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
loader: LoaderInfo
|
|
||||||
currentLoader: string | null
|
|
||||||
loaderVersion: string | null
|
|
||||||
isInstalling?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'select', loader: string): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isCurrentLoader = computed(() => {
|
|
||||||
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase()
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSelect = () => {
|
|
||||||
emit('select', props.loader.name)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
|
|
||||||
@mouseenter="checkOverflow"
|
|
||||||
@touchstart="checkOverflow"
|
|
||||||
>
|
|
||||||
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
|
|
||||||
<span v-html="sanitizedLog"></span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="isOverflowing"
|
|
||||||
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
|
|
||||||
type="button"
|
|
||||||
@click.stop="$emit('show-full-log', props.log)"
|
|
||||||
>
|
|
||||||
...
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Convert from 'ansi-to-html'
|
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
log: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'show-full-log': [log: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const logContent = ref<HTMLElement | null>(null)
|
|
||||||
const isOverflowing = ref(false)
|
|
||||||
|
|
||||||
const checkOverflow = () => {
|
|
||||||
if (logContent.value && !isOverflowing.value) {
|
|
||||||
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const convert = new Convert({
|
|
||||||
fg: '#FFF',
|
|
||||||
bg: '#000',
|
|
||||||
newline: false,
|
|
||||||
escapeXML: true,
|
|
||||||
stream: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sanitizedLog = computed(() =>
|
|
||||||
DOMPurify.sanitize(convert.toHtml(props.log), {
|
|
||||||
ALLOWED_TAGS: ['span'],
|
|
||||||
ALLOWED_ATTR: ['style'],
|
|
||||||
USE_PROFILES: { html: true },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const preventSelection = (e: MouseEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
logContent.value?.addEventListener('mousedown', preventSelection)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
logContent.value?.removeEventListener('mousedown', preventSelection)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.parsed-log {
|
|
||||||
background: transparent;
|
|
||||||
transition: background-color 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parsed-log:hover {
|
|
||||||
background: rgba(128, 128, 128, 0.25);
|
|
||||||
transition: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-content > span {
|
|
||||||
user-select: none;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-content {
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="contents">
|
|
||||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
|
|
||||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
|
||||||
<p class="m-0">
|
|
||||||
Are you sure you want to
|
|
||||||
<span class="lowercase">{{ pendingAction }}</span> the server?
|
|
||||||
</p>
|
|
||||||
<Checkbox
|
|
||||||
v-model="dontAskAgain"
|
|
||||||
label="Don't ask me again"
|
|
||||||
class="text-sm"
|
|
||||||
:disabled="!pendingAction"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-row gap-4">
|
|
||||||
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
|
|
||||||
<button>
|
|
||||||
<CheckIcon class="h-5 w-5" />
|
|
||||||
{{ pendingAction }} server
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled @click="resetPowerAction">
|
|
||||||
<button>
|
|
||||||
<XIcon class="h-5 w-5" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NewModal>
|
|
||||||
|
|
||||||
<NewModal
|
|
||||||
ref="detailsModal"
|
|
||||||
:header="`All of ${server.name || 'Server'} info`"
|
|
||||||
@close="detailsModal?.hide()"
|
|
||||||
>
|
|
||||||
<ServerInfoLabels
|
|
||||||
:server-data="server"
|
|
||||||
:show-game-label="true"
|
|
||||||
:show-loader-label="true"
|
|
||||||
:uptime-seconds="uptimeSeconds"
|
|
||||||
:column="true"
|
|
||||||
class="mb-6 flex flex-col gap-2"
|
|
||||||
/>
|
|
||||||
<div v-if="flags.advancedDebugInfo" class="markdown-body">
|
|
||||||
<pre>{{ server }}</pre>
|
|
||||||
</div>
|
|
||||||
<ButtonStyled type="standard" color="brand" @click="detailsModal?.hide()">
|
|
||||||
<button class="w-full">Close</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</NewModal>
|
|
||||||
|
|
||||||
<div class="flex flex-row items-center gap-2 rounded-lg">
|
|
||||||
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
|
|
||||||
<button disabled class="flex-shrink-0">
|
|
||||||
<PanelSpinner class="size-5" /> Installing...
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<ButtonStyled v-if="showStopButton" type="transparent" size="large">
|
|
||||||
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<StopCircleIcon class="h-5 w-5" />
|
|
||||||
<span>{{ isStopping ? 'Stopping...' : 'Stop' }}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<ButtonStyled type="standard" color="brand" size="large">
|
|
||||||
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
|
||||||
<div v-if="isTransitioning" class="grid place-content-center">
|
|
||||||
<LoadingIcon />
|
|
||||||
</div>
|
|
||||||
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
|
|
||||||
<span>{{ primaryActionText }}</span>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<ButtonStyled circular type="transparent" size="large">
|
|
||||||
<TeleportOverflowMenu :options="[...menuOptions]">
|
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
|
||||||
<template #kill>
|
|
||||||
<SlashIcon class="h-5 w-5" />
|
|
||||||
<span>Kill server</span>
|
|
||||||
</template>
|
|
||||||
<template #allServers>
|
|
||||||
<ServerIcon class="h-5 w-5" />
|
|
||||||
<span>All servers</span>
|
|
||||||
</template>
|
|
||||||
<template #details>
|
|
||||||
<InfoIcon class="h-5 w-5" />
|
|
||||||
<span>Details</span>
|
|
||||||
</template>
|
|
||||||
<template #copy-id>
|
|
||||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
|
||||||
<span>Copy ID</span>
|
|
||||||
</template>
|
|
||||||
</TeleportOverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
CheckIcon,
|
|
||||||
ClipboardCopyIcon,
|
|
||||||
InfoIcon,
|
|
||||||
MoreVerticalIcon,
|
|
||||||
PlayIcon,
|
|
||||||
ServerIcon,
|
|
||||||
SlashIcon,
|
|
||||||
StopCircleIcon,
|
|
||||||
UpdatedIcon,
|
|
||||||
XIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
ButtonStyled,
|
|
||||||
Checkbox,
|
|
||||||
injectModrinthClient,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
injectNotificationManager,
|
|
||||||
NewModal,
|
|
||||||
ServerInfoLabels,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { useStorage } from '@vueuse/core'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
|
||||||
import PanelSpinner from './PanelSpinner.vue'
|
|
||||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
|
||||||
|
|
||||||
type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
disabled?: boolean
|
|
||||||
uptimeSeconds: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const flags = useFeatureFlags()
|
|
||||||
const router = useRouter()
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
|
|
||||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null)
|
|
||||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null)
|
|
||||||
const pendingAction = ref<PowerAction | null>(null)
|
|
||||||
const dontAskAgain = ref(false)
|
|
||||||
|
|
||||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
|
||||||
powerDontAskAgain: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isInstalling = computed(() => server.value.status === 'installing')
|
|
||||||
const isRunning = computed(() => powerState.value === 'running')
|
|
||||||
const isStopping = computed(() => powerState.value === 'stopping')
|
|
||||||
const isTransitioning = computed(
|
|
||||||
() => powerState.value === 'starting' || powerState.value === 'stopping',
|
|
||||||
)
|
|
||||||
const showStopButton = computed(() => isRunning.value || isStopping.value)
|
|
||||||
|
|
||||||
const busyTooltip = computed(() =>
|
|
||||||
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
const canTakeAction = computed(
|
|
||||||
() => !isTransitioning.value && !props.disabled && busyReasons.value.length === 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
const primaryActionText = computed(() => {
|
|
||||||
switch (powerState.value) {
|
|
||||||
case 'starting':
|
|
||||||
return 'Starting...'
|
|
||||||
case 'stopping':
|
|
||||||
return 'Stopping...'
|
|
||||||
case 'running':
|
|
||||||
return 'Restart'
|
|
||||||
default:
|
|
||||||
return 'Start'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const menuOptions = computed(() => [
|
|
||||||
...(isInstalling.value
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: 'kill',
|
|
||||||
label: 'Kill server',
|
|
||||||
icon: SlashIcon,
|
|
||||||
action: () => initiateAction('Kill'),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
id: 'allServers',
|
|
||||||
label: 'All servers',
|
|
||||||
icon: ServerIcon,
|
|
||||||
action: () => router.push('/hosting/manage'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'details',
|
|
||||||
label: 'Details',
|
|
||||||
icon: InfoIcon,
|
|
||||||
action: () => detailsModal.value?.show(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'copy-id',
|
|
||||||
label: 'Copy ID',
|
|
||||||
icon: ClipboardCopyIcon,
|
|
||||||
action: () => copyId(),
|
|
||||||
shown: flags.value.developerMode,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
async function copyId() {
|
|
||||||
await navigator.clipboard.writeText(serverId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendPowerAction(action: PowerAction) {
|
|
||||||
try {
|
|
||||||
await client.archon.servers_v0.power(serverId, action)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error performing ${action} on server:`, error)
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: `Failed to ${action.toLowerCase()} server`,
|
|
||||||
text: 'An error occurred while performing this action.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initiateAction(action: PowerAction) {
|
|
||||||
if (!canTakeAction.value) return
|
|
||||||
|
|
||||||
if (action === 'Start') {
|
|
||||||
sendPowerAction(action)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingAction.value = action
|
|
||||||
|
|
||||||
if (userPreferences.value.powerDontAskAgain) {
|
|
||||||
executePowerAction()
|
|
||||||
} else {
|
|
||||||
confirmActionModal.value?.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePrimaryAction() {
|
|
||||||
initiateAction(isRunning.value ? 'Restart' : 'Start')
|
|
||||||
}
|
|
||||||
|
|
||||||
function executePowerAction() {
|
|
||||||
if (!pendingAction.value) return
|
|
||||||
|
|
||||||
sendPowerAction(pendingAction.value)
|
|
||||||
|
|
||||||
if (dontAskAgain.value) {
|
|
||||||
userPreferences.value.powerDontAskAgain = true
|
|
||||||
}
|
|
||||||
|
|
||||||
resetPowerAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetPowerAction() {
|
|
||||||
confirmActionModal.value?.hide()
|
|
||||||
pendingAction.value = null
|
|
||||||
dontAskAgain.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
:aria-label="`Server is ${getStatusText(state)}`"
|
|
||||||
class="relative inline-flex select-none items-center"
|
|
||||||
@mouseenter="isExpanded = true"
|
|
||||||
@mouseleave="isExpanded = false"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
|
|
||||||
getStatusClass(state).main,
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
|
|
||||||
getStatusClass(state).bg,
|
|
||||||
]"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
|
|
||||||
getStatusClass(state).bg,
|
|
||||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="h-3 w-3 rounded-full"></div>
|
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
|
|
||||||
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ getStatusText(state) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ServerState } from '@modrinth/utils'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const STATUS_CLASSES = {
|
|
||||||
running: { main: 'bg-brand', bg: 'bg-bg-green' },
|
|
||||||
stopped: { main: '', bg: '' },
|
|
||||||
crashed: { main: 'bg-brand-red', bg: 'bg-bg-red' },
|
|
||||||
unknown: { main: '', bg: '' },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const STATUS_TEXTS: Partial<Record<ServerState, string>> = {
|
|
||||||
running: 'Running',
|
|
||||||
stopped: '',
|
|
||||||
crashed: 'Crashed',
|
|
||||||
unknown: 'Unknown',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
state: ServerState
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isExpanded = ref(false)
|
|
||||||
|
|
||||||
function getStatusClass(state: ServerState) {
|
|
||||||
if (state in STATUS_CLASSES) {
|
|
||||||
return STATUS_CLASSES[state as keyof typeof STATUS_CLASSES]
|
|
||||||
}
|
|
||||||
return STATUS_CLASSES.unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusText(state: ServerState) {
|
|
||||||
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
class="h-5 w-5 animate-spin"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,163 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NewModal
|
|
||||||
ref="modal"
|
|
||||||
:header="'Changing ' + props.project?.title + ' version'"
|
|
||||||
@hide="onHide"
|
|
||||||
@show="onShow"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<p class="m-0">
|
|
||||||
Select the version of {{ props.project?.title || 'the modpack' }} you want to install on
|
|
||||||
your server.
|
|
||||||
</p>
|
|
||||||
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
|
|
||||||
Currently installed: {{ props.currentVersion.version_number }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-4">
|
|
||||||
<Combobox
|
|
||||||
v-if="props.versions?.length"
|
|
||||||
v-model="selectedVersion"
|
|
||||||
:options="versionOptions.map((v) => ({ value: v, label: v }))"
|
|
||||||
:display-value="selectedVersion || 'Select version...'"
|
|
||||||
placeholder="Select version..."
|
|
||||||
name="version"
|
|
||||||
class="w-full max-w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
|
||||||
<div class="flex w-full flex-row items-center justify-between">
|
|
||||||
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
|
|
||||||
Erase all data
|
|
||||||
</label>
|
|
||||||
<Toggle id="modpack-hard-reset" v-model="hardReset" class="shrink-0" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
If enabled, existing mods, worlds, and configurations, will be deleted before installing
|
|
||||||
the new modpack version.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-start gap-4">
|
|
||||||
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
|
|
||||||
<button
|
|
||||||
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
|
|
||||||
@click="handleReinstall"
|
|
||||||
>
|
|
||||||
<DownloadIcon class="size-4" />
|
|
||||||
{{ isLoading ? 'Installing...' : hardReset ? 'Erase and install' : 'Install' }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button :disabled="isLoading" @click="hide">
|
|
||||||
<XIcon />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NewModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
ButtonStyled,
|
|
||||||
Combobox,
|
|
||||||
injectModrinthClient,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
injectNotificationManager,
|
|
||||||
NewModal,
|
|
||||||
Toggle,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { ModrinthServersFetchError } from '@modrinth/utils'
|
|
||||||
|
|
||||||
const { serverId } = injectModrinthServerContext()
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
project: any
|
|
||||||
versions: any[]
|
|
||||||
currentVersion?: any
|
|
||||||
currentVersionId?: string
|
|
||||||
serverStatus?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
reinstall: [any?]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const modal = ref()
|
|
||||||
const hardReset = ref(false)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const selectedVersion = ref(props.currentVersion?.version_number || '')
|
|
||||||
|
|
||||||
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || [])
|
|
||||||
|
|
||||||
const handleReinstall = async () => {
|
|
||||||
if (!selectedVersion.value || !props.project?.id) return
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id
|
|
||||||
|
|
||||||
await client.archon.servers_v0.reinstall(
|
|
||||||
serverId,
|
|
||||||
{
|
|
||||||
project_id: props.project.id,
|
|
||||||
version_id: versionId,
|
|
||||||
},
|
|
||||||
hardReset.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
emit('reinstall')
|
|
||||||
hide()
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
|
|
||||||
addNotification({
|
|
||||||
title: 'Cannot reinstall server',
|
|
||||||
text: 'You are being rate limited. Please try again later.',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
addNotification({
|
|
||||||
title: 'Reinstall Failed',
|
|
||||||
text: 'An unexpected error occurred while reinstalling. Please try again later.',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.serverStatus,
|
|
||||||
(newStatus) => {
|
|
||||||
if (newStatus === 'installing') {
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const onShow = () => {
|
|
||||||
hardReset.value = false
|
|
||||||
selectedVersion.value =
|
|
||||||
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const onHide = () => {
|
|
||||||
hardReset.value = false
|
|
||||||
selectedVersion.value = ''
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const show = () => modal.value?.show()
|
|
||||||
const hide = () => modal.value?.hide()
|
|
||||||
|
|
||||||
defineExpose({ show, hide })
|
|
||||||
</script>
|
|
||||||
@@ -1,538 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NewModal
|
|
||||||
ref="versionSelectModal"
|
|
||||||
:header="
|
|
||||||
isSecondPhase
|
|
||||||
? 'Confirming reinstallation'
|
|
||||||
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
|
|
||||||
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
|
|
||||||
"
|
|
||||||
@hide="onHide"
|
|
||||||
@show="onShow"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
|
||||||
<p
|
|
||||||
v-if="isSecondPhase"
|
|
||||||
:style="{
|
|
||||||
lineHeight: isSecondPhase ? '1.5' : undefined,
|
|
||||||
marginBottom: isSecondPhase ? '-12px' : '0',
|
|
||||||
marginTop: isSecondPhase ? '-4px' : '-2px',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
'This will reinstall your server and erase all data. Are you sure you want to continue?'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
|
|
||||||
<div class="mx-auto flex flex-row items-center gap-4">
|
|
||||||
<div
|
|
||||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
|
||||||
>
|
|
||||||
<LoaderIcon class="size-10" :loader="selectedLoader" />
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="size-10"
|
|
||||||
>
|
|
||||||
<path d="M5 9v6" />
|
|
||||||
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
|
||||||
>
|
|
||||||
<ServerIcon class="size-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
|
||||||
<div class="text-lg font-bold text-contrast">Minecraft version</div>
|
|
||||||
<Combobox
|
|
||||||
v-model="selectedMCVersion"
|
|
||||||
name="mcVersion"
|
|
||||||
:options="mcVersions.map((v) => ({ value: v, label: v }))"
|
|
||||||
:display-value="selectedMCVersion || 'Select Minecraft version...'"
|
|
||||||
class="!w-full"
|
|
||||||
placeholder="Select Minecraft version..."
|
|
||||||
/>
|
|
||||||
<div class="mt-2 flex items-center justify-between gap-2">
|
|
||||||
<label for="toggle-snapshots" class="font-semibold"> Show snapshot versions </label>
|
|
||||||
<div
|
|
||||||
v-tooltip="
|
|
||||||
isSnapshotSelected ? 'A snapshot version is currently selected.' : undefined
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<Toggle
|
|
||||||
id="toggle-snapshots"
|
|
||||||
v-model="showSnapshots"
|
|
||||||
:disabled="isSnapshotSelected"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
|
|
||||||
class="flex w-full flex-col gap-2 rounded-2xl p-4"
|
|
||||||
:class="{
|
|
||||||
'bg-table-alternateRow':
|
|
||||||
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
|
|
||||||
'bg-highlight-red':
|
|
||||||
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="text-lg font-bold text-contrast">{{ selectedLoader }} version</div>
|
|
||||||
|
|
||||||
<template v-if="!selectedMCVersion">
|
|
||||||
<div
|
|
||||||
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
|
|
||||||
>
|
|
||||||
Select a Minecraft version to see available versions
|
|
||||||
<DropdownIcon class="absolute right-4" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="isLoading">
|
|
||||||
<div
|
|
||||||
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
|
|
||||||
>
|
|
||||||
<LoadingIcon class="mr-2 animate-spin" />
|
|
||||||
Loading versions...
|
|
||||||
<DropdownIcon class="absolute right-4" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="selectedLoaderVersions.length > 0">
|
|
||||||
<Combobox
|
|
||||||
v-model="selectedLoaderVersion"
|
|
||||||
name="loaderVersion"
|
|
||||||
:options="selectedLoaderVersions.map((v) => ({ value: v, label: v }))"
|
|
||||||
:display-value="
|
|
||||||
selectedLoaderVersion ||
|
|
||||||
(selectedLoader.toLowerCase() === 'paper' ||
|
|
||||||
selectedLoader.toLowerCase() === 'purpur'
|
|
||||||
? 'Select build number...'
|
|
||||||
: 'Select loader version...')
|
|
||||||
"
|
|
||||||
class="w-full max-w-[100%]"
|
|
||||||
:placeholder="
|
|
||||||
selectedLoader.toLowerCase() === 'paper' ||
|
|
||||||
selectedLoader.toLowerCase() === 'purpur'
|
|
||||||
? `Select build number...`
|
|
||||||
: `Select loader version...`
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!initialSetup"
|
|
||||||
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
|
|
||||||
>
|
|
||||||
<div class="flex w-full flex-row items-center justify-between">
|
|
||||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
|
||||||
Erase all data
|
|
||||||
</label>
|
|
||||||
<Toggle id="hard-reset" v-model="hardReset" class="shrink-0" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
|
||||||
then reinstalls it with the selected version.
|
|
||||||
</div>
|
|
||||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BackupWarning v-if="!initialSetup" :backup-link="`/hosting/manage/${serverId}/backups`" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-start gap-4">
|
|
||||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
|
||||||
<button
|
|
||||||
v-tooltip="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
|
|
||||||
:disabled="canInstall || busyReasons.length > 0"
|
|
||||||
@click="handleReinstall"
|
|
||||||
>
|
|
||||||
<RightArrowIcon />
|
|
||||||
{{
|
|
||||||
isLoading
|
|
||||||
? 'Installing...'
|
|
||||||
: isSecondPhase
|
|
||||||
? 'Erase and install'
|
|
||||||
: hardReset
|
|
||||||
? 'Continue'
|
|
||||||
: 'Install'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button
|
|
||||||
:disabled="isLoading"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
if (isSecondPhase) {
|
|
||||||
isSecondPhase = false
|
|
||||||
} else {
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
{{ isSecondPhase ? 'Go back' : 'Cancel' }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NewModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
BackupWarning,
|
|
||||||
ButtonStyled,
|
|
||||||
Combobox,
|
|
||||||
injectModrinthClient,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
injectNotificationManager,
|
|
||||||
NewModal,
|
|
||||||
Toggle,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
|
|
||||||
import { $fetch } from 'ofetch'
|
|
||||||
|
|
||||||
import LoaderIcon from './icons/LoaderIcon.vue'
|
|
||||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
|
||||||
|
|
||||||
const { server, serverId, busyReasons } = injectModrinthServerContext()
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
|
|
||||||
interface LoaderVersion {
|
|
||||||
id: string
|
|
||||||
stable: boolean
|
|
||||||
loaders: {
|
|
||||||
id: string
|
|
||||||
url: string
|
|
||||||
stable: boolean
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type VersionMap = Record<string, LoaderVersion[]>
|
|
||||||
type VersionCache = Record<string, any>
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
currentLoader: Loaders | undefined
|
|
||||||
initialSetup?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
reinstall: [any?]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const versionSelectModal = ref()
|
|
||||||
const isSecondPhase = ref(false)
|
|
||||||
const hardReset = ref(false)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const loadingServerCheck = ref(false)
|
|
||||||
const serverCheckError = ref('')
|
|
||||||
const showSnapshots = ref(false)
|
|
||||||
|
|
||||||
const selectedLoader = ref<Loaders>('Vanilla')
|
|
||||||
const selectedMCVersion = ref('')
|
|
||||||
const selectedLoaderVersion = ref('')
|
|
||||||
|
|
||||||
const paperVersions = ref<Record<string, number[]>>({})
|
|
||||||
const purpurVersions = ref<Record<string, string[]>>({})
|
|
||||||
const loaderVersions = ref<VersionMap>({})
|
|
||||||
const cachedVersions = ref<VersionCache>({})
|
|
||||||
|
|
||||||
const versionStrings = ['forge', 'fabric', 'quilt', 'neo'] as const
|
|
||||||
|
|
||||||
const isSnapshotSelected = computed(() => {
|
|
||||||
if (selectedMCVersion.value) {
|
|
||||||
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value)
|
|
||||||
if (selected?.version_type !== 'release') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const getLoaderVersions = async (loader: string) => {
|
|
||||||
return await $fetch(
|
|
||||||
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchLoaderVersions = async () => {
|
|
||||||
const versions = await Promise.all(
|
|
||||||
versionStrings.map(async (loader) => {
|
|
||||||
const runFetch = async (iterations: number) => {
|
|
||||||
if (iterations > 5) {
|
|
||||||
throw new Error('Failed to fetch loader versions')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await getLoaderVersions(loader)
|
|
||||||
return { [loader]: (res as any).gameVersions }
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
} catch (_) {
|
|
||||||
return await runFetch(iterations + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await runFetch(0)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
return { [loader]: [] }
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPaperVersions = async (mcVersion: string) => {
|
|
||||||
try {
|
|
||||||
const res = await $fetch(`https://fill.papermc.io/v3/projects/paper/versions/${mcVersion}`)
|
|
||||||
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a)
|
|
||||||
return res
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPurpurVersions = async (mcVersion: string) => {
|
|
||||||
try {
|
|
||||||
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`)
|
|
||||||
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
|
|
||||||
(a: string, b: string) => parseInt(b) - parseInt(a),
|
|
||||||
)
|
|
||||||
return res
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedLoaderVersions = computed<string[]>(() => {
|
|
||||||
const loader = selectedLoader.value.toLowerCase()
|
|
||||||
|
|
||||||
if (loader === 'paper') {
|
|
||||||
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loader === 'purpur') {
|
|
||||||
return purpurVersions.value[selectedMCVersion.value] || []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loader === 'vanilla') {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
let apiLoader = loader
|
|
||||||
if (loader === 'neoforge') {
|
|
||||||
apiLoader = 'neo'
|
|
||||||
}
|
|
||||||
|
|
||||||
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
|
|
||||||
(x) => x.id === '${modrinth.gameVersion}',
|
|
||||||
)
|
|
||||||
|
|
||||||
if (backwardsCompatibleVersion) {
|
|
||||||
return backwardsCompatibleVersion.loaders.map((x) => x.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
loaderVersions.value[apiLoader]
|
|
||||||
?.find((x) => x.id === selectedMCVersion.value)
|
|
||||||
?.loaders.map((x) => x.id) || []
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedLoader, async () => {
|
|
||||||
if (selectedMCVersion.value) {
|
|
||||||
selectedLoaderVersion.value = ''
|
|
||||||
serverCheckError.value = ''
|
|
||||||
|
|
||||||
await checkVersionAvailability(selectedMCVersion.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
selectedLoaderVersions,
|
|
||||||
(newVersions) => {
|
|
||||||
if (
|
|
||||||
newVersions.length > 0 &&
|
|
||||||
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
|
|
||||||
) {
|
|
||||||
selectedLoaderVersion.value = String(newVersions[0])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const getLoaderVersion = async (loader: string, version: string) => {
|
|
||||||
return await $fetch(
|
|
||||||
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkVersionAvailability = async (version: string) => {
|
|
||||||
if (!version || version.trim().length < 3) return
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
loadingServerCheck.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mcRes = cachedVersions.value[version] || (await getLoaderVersion('minecraft', version))
|
|
||||||
|
|
||||||
cachedVersions.value[version] = mcRes
|
|
||||||
|
|
||||||
if (!mcRes.downloads?.server) {
|
|
||||||
serverCheckError.value = "We couldn't find a server.jar for this version."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const loader = selectedLoader.value.toLowerCase()
|
|
||||||
if (loader === 'paper' || loader === 'purpur') {
|
|
||||||
const fetchFn = loader === 'paper' ? fetchPaperVersions : fetchPurpurVersions
|
|
||||||
const result = await fetchFn(version)
|
|
||||||
if (!result) {
|
|
||||||
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
serverCheckError.value = ''
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
serverCheckError.value = 'Failed to fetch versions.'
|
|
||||||
} finally {
|
|
||||||
loadingServerCheck.value = false
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selectedMCVersion, checkVersionAvailability)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchLoaderVersions()
|
|
||||||
})
|
|
||||||
|
|
||||||
const tags = useGeneratedState()
|
|
||||||
const mcVersions = computed(() =>
|
|
||||||
tags.value.gameVersions
|
|
||||||
.filter((x) =>
|
|
||||||
showSnapshots.value
|
|
||||||
? x.version_type === 'snapshot' || x.version_type === 'release'
|
|
||||||
: x.version_type === 'release',
|
|
||||||
)
|
|
||||||
.map((x) => x.version),
|
|
||||||
)
|
|
||||||
|
|
||||||
const isDangerous = computed(() => hardReset.value)
|
|
||||||
const canInstall = computed(() => {
|
|
||||||
const conds =
|
|
||||||
!selectedMCVersion.value ||
|
|
||||||
isLoading.value ||
|
|
||||||
loadingServerCheck.value ||
|
|
||||||
serverCheckError.value.trim().length > 0
|
|
||||||
|
|
||||||
if (selectedLoader.value.toLowerCase() === 'vanilla') {
|
|
||||||
return conds
|
|
||||||
}
|
|
||||||
|
|
||||||
return conds || !selectedLoaderVersion.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleReinstall = async () => {
|
|
||||||
if (hardReset.value && !isSecondPhase.value) {
|
|
||||||
isSecondPhase.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.archon.servers_v0.reinstall(
|
|
||||||
serverId,
|
|
||||||
{
|
|
||||||
loader: selectedLoader.value,
|
|
||||||
loader_version:
|
|
||||||
selectedLoader.value === 'Vanilla' ? undefined : selectedLoaderVersion.value || undefined,
|
|
||||||
game_version: selectedMCVersion.value,
|
|
||||||
},
|
|
||||||
props.initialSetup ? true : hardReset.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
emit('reinstall', {
|
|
||||||
loader: selectedLoader.value,
|
|
||||||
lVersion: selectedLoaderVersion.value,
|
|
||||||
mVersion: selectedMCVersion.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
hide()
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
|
|
||||||
addNotification({
|
|
||||||
title: 'Cannot reinstall server',
|
|
||||||
text: 'You are being rate limited. Please try again later.',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
addNotification({
|
|
||||||
title: 'Reinstall Failed',
|
|
||||||
text: 'An unexpected error occurred while reinstalling. Please try again later.',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onShow = () => {
|
|
||||||
selectedMCVersion.value = server.value?.mc_version || ''
|
|
||||||
if (isSnapshotSelected.value) {
|
|
||||||
showSnapshots.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onHide = () => {
|
|
||||||
hardReset.value = false
|
|
||||||
isSecondPhase.value = false
|
|
||||||
serverCheckError.value = ''
|
|
||||||
loadingServerCheck.value = false
|
|
||||||
isLoading.value = false
|
|
||||||
selectedMCVersion.value = ''
|
|
||||||
serverCheckError.value = ''
|
|
||||||
paperVersions.value = {}
|
|
||||||
purpurVersions.value = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const show = (loader: Loaders) => {
|
|
||||||
if (selectedLoader.value !== loader) {
|
|
||||||
selectedLoaderVersion.value = ''
|
|
||||||
}
|
|
||||||
selectedLoader.value = loader
|
|
||||||
selectedMCVersion.value = server.value?.mc_version || ''
|
|
||||||
versionSelectModal.value?.show()
|
|
||||||
}
|
|
||||||
const hide = () => versionSelectModal.value?.hide()
|
|
||||||
|
|
||||||
defineExpose({ show, hide })
|
|
||||||
</script>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Transition name="save-banner">
|
|
||||||
<div
|
|
||||||
v-if="props.isVisible"
|
|
||||||
data-pyro-save-banner
|
|
||||||
class="fixed bottom-16 left-0 right-0 z-[10] mx-auto h-fit w-full max-w-4xl transition-all duration-300 sm:bottom-8"
|
|
||||||
>
|
|
||||||
<div class="mx-2 rounded-2xl border-2 border-solid border-button-border bg-bg-raised p-4">
|
|
||||||
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
|
|
||||||
<span class="font-bold text-contrast">Careful, you have unsaved changes!</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<ButtonStyled type="transparent" color="standard">
|
|
||||||
<button :disabled="props.isUpdating" @click="props.reset">Reset</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="standard" :color="props.restart ? 'standard' : 'brand'">
|
|
||||||
<button :disabled="props.isUpdating" @click="props.save">
|
|
||||||
{{ props.isUpdating ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-if="props.restart" type="standard" color="brand">
|
|
||||||
<button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
|
|
||||||
{{ powerButtonLabel }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ButtonStyled, injectModrinthClient, injectModrinthServerContext } from '@modrinth/ui'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
isUpdating: boolean
|
|
||||||
restart?: boolean
|
|
||||||
save: () => void
|
|
||||||
reset: () => void
|
|
||||||
isVisible: boolean
|
|
||||||
serverId: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const { powerState } = injectModrinthServerContext()
|
|
||||||
|
|
||||||
const isStopped = computed(() => powerState.value === 'stopped' || powerState.value === 'crashed')
|
|
||||||
|
|
||||||
const isTransitioning = computed(
|
|
||||||
() => powerState.value === 'starting' || powerState.value === 'stopping',
|
|
||||||
)
|
|
||||||
|
|
||||||
const powerButtonLabel = computed(() => {
|
|
||||||
if (props.isUpdating) return 'Saving...'
|
|
||||||
if (isTransitioning.value) return isStopped.value ? 'Save & start' : 'Save & restart'
|
|
||||||
return isStopped.value ? 'Save & start' : 'Save & restart'
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveAndPower = async () => {
|
|
||||||
props.save()
|
|
||||||
await client.archon.servers_v0.power(props.serverId, isStopped.value ? 'Start' : 'Restart')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.save-banner-enter-active {
|
|
||||||
transition:
|
|
||||||
opacity 300ms,
|
|
||||||
transform 300ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-banner-leave-active {
|
|
||||||
transition:
|
|
||||||
opacity 200ms,
|
|
||||||
transform 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-banner-enter-from,
|
|
||||||
.save-banner-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(100%) scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-banner-enter-to,
|
|
||||||
.save-banner-leave-from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="static w-full grid-cols-1 md:relative md:flex">
|
|
||||||
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
|
|
||||||
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
|
|
||||||
<div
|
|
||||||
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
|
|
||||||
:key="link.label"
|
|
||||||
>
|
|
||||||
<NuxtLink
|
|
||||||
:to="link.href"
|
|
||||||
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
|
||||||
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 font-bold">
|
|
||||||
<component :is="link.icon" class="size-6" />
|
|
||||||
{{ link.label }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<RightArrowIcon v-if="link.external" class="size-4" />
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-full w-full">
|
|
||||||
<NuxtPage :route="route" @reinstall="onReinstall" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { RightArrowIcon } from '@modrinth/assets'
|
|
||||||
import type { RouteLocationNormalized } from 'vue-router'
|
|
||||||
|
|
||||||
const emit = defineEmits(['reinstall'])
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
navLinks: {
|
|
||||||
label: string
|
|
||||||
href: string
|
|
||||||
icon: Component
|
|
||||||
external?: boolean
|
|
||||||
shown?: boolean
|
|
||||||
}[]
|
|
||||||
route: RouteLocationNormalized
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const onReinstall = (...args: any[]) => {
|
|
||||||
emit('reinstall', ...args)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
data-pyro-server-stats
|
|
||||||
style="font-variant-numeric: tabular-nums"
|
|
||||||
class="flex select-none flex-col items-center gap-6 md:flex-row"
|
|
||||||
:class="{ 'pointer-events-none': loading }"
|
|
||||||
:aria-hidden="loading"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(metric, index) in metrics"
|
|
||||||
:key="index"
|
|
||||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
|
||||||
>
|
|
||||||
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
|
||||||
<div class="relative z-10">
|
|
||||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
|
||||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">
|
|
||||||
{{ metric.value }}
|
|
||||||
</h2>
|
|
||||||
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
|
|
||||||
</div>
|
|
||||||
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
|
|
||||||
{{ metric.title }}
|
|
||||||
<IssuesIcon
|
|
||||||
v-if="metric.warning && !loading"
|
|
||||||
v-tooltip="metric.warning"
|
|
||||||
class="size-5"
|
|
||||||
:style="{ color: 'var(--color-orange)' }"
|
|
||||||
/>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<component
|
|
||||||
:is="metric.icon"
|
|
||||||
class="absolute right-10 top-10 z-10 size-8"
|
|
||||||
style="width: 2rem; height: 2rem"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="chart-space absolute bottom-0 left-0 right-0">
|
|
||||||
<ClientOnly>
|
|
||||||
<VueApexCharts
|
|
||||||
v-if="metric.showGraph && !loading"
|
|
||||||
type="area"
|
|
||||||
height="142"
|
|
||||||
:options="getChartOptions(metric.warning, index)"
|
|
||||||
:series="[{ name: metric.title, data: metric.data }]"
|
|
||||||
class="chart"
|
|
||||||
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nuxt-link
|
|
||||||
:to="loading ? undefined : `/hosting/manage/${serverId}/files`"
|
|
||||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
|
||||||
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
|
|
||||||
>
|
|
||||||
<div class="flex flex-row items-center gap-2">
|
|
||||||
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
|
|
||||||
{{ loading ? '0 B' : formatBytes(stats.storage_usage_bytes) }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
|
||||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { CpuIcon, DatabaseIcon, FolderOpenIcon, IssuesIcon } from '@modrinth/assets'
|
|
||||||
import type { Stats } from '@modrinth/utils'
|
|
||||||
import { useStorage } from '@vueuse/core'
|
|
||||||
import { computed, ref, shallowRef } from 'vue'
|
|
||||||
|
|
||||||
const flags = useFeatureFlags()
|
|
||||||
const route = useNativeRoute()
|
|
||||||
const serverId = route.params.id
|
|
||||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
|
||||||
|
|
||||||
const chartsReady = ref(new Set<number>())
|
|
||||||
|
|
||||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
|
||||||
ramAsNumber: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ data?: Stats; loading?: boolean }>(), {
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const stats = shallowRef(
|
|
||||||
props.data?.current || {
|
|
||||||
cpu_percent: 0,
|
|
||||||
ram_usage_bytes: 0,
|
|
||||||
ram_total_bytes: 1, // Avoid division by zero
|
|
||||||
storage_usage_bytes: 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const onChartReady = (index: number) => {
|
|
||||||
chartsReady.value.add(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
|
||||||
let value = bytes
|
|
||||||
let unit = 0
|
|
||||||
while (value >= 1024 && unit < units.length - 1) {
|
|
||||||
value /= 1024
|
|
||||||
unit++
|
|
||||||
}
|
|
||||||
return `${Math.round(value * 10) / 10} ${units[unit]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const cpuData = ref<number[]>(Array(20).fill(0))
|
|
||||||
const ramData = ref<number[]>(Array(20).fill(0))
|
|
||||||
|
|
||||||
const updateGraphData = (arr: number[], newValue: number) => {
|
|
||||||
arr.push(newValue)
|
|
||||||
arr.shift()
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = computed(() => {
|
|
||||||
if (props.loading) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: 'CPU usage',
|
|
||||||
value: '0.00%',
|
|
||||||
max: '100%',
|
|
||||||
icon: CpuIcon,
|
|
||||||
data: cpuData.value,
|
|
||||||
showGraph: false,
|
|
||||||
warning: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Memory usage',
|
|
||||||
value: '0.00%',
|
|
||||||
max: '100%',
|
|
||||||
icon: DatabaseIcon,
|
|
||||||
data: ramData.value,
|
|
||||||
showGraph: false,
|
|
||||||
warning: null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const ramPercent = Math.min(
|
|
||||||
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
|
|
||||||
100,
|
|
||||||
)
|
|
||||||
const cpuPercent = Math.min(stats.value.cpu_percent, 100)
|
|
||||||
|
|
||||||
updateGraphData(cpuData.value, cpuPercent)
|
|
||||||
updateGraphData(ramData.value, ramPercent)
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: 'CPU usage',
|
|
||||||
value: `${cpuPercent.toFixed(2)}%`,
|
|
||||||
max: '100%',
|
|
||||||
icon: CpuIcon,
|
|
||||||
data: cpuData.value,
|
|
||||||
showGraph: true,
|
|
||||||
warning: cpuPercent >= 90 ? 'CPU usage is very high' : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Memory usage',
|
|
||||||
value:
|
|
||||||
userPreferences.value.ramAsNumber || flags.developerMode
|
|
||||||
? formatBytes(stats.value.ram_usage_bytes)
|
|
||||||
: `${ramPercent.toFixed(2)}%`,
|
|
||||||
max:
|
|
||||||
userPreferences.value.ramAsNumber || flags.developerMode
|
|
||||||
? formatBytes(stats.value.ram_total_bytes)
|
|
||||||
: '100%',
|
|
||||||
icon: DatabaseIcon,
|
|
||||||
data: ramData.value,
|
|
||||||
showGraph: true,
|
|
||||||
warning: ramPercent >= 90 ? 'Memory usage is very high' : null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const getChartOptions = (hasWarning: string | null, index: number) => ({
|
|
||||||
chart: {
|
|
||||||
type: 'area',
|
|
||||||
animations: { enabled: false },
|
|
||||||
sparkline: { enabled: true },
|
|
||||||
toolbar: { show: false },
|
|
||||||
padding: {
|
|
||||||
left: -10,
|
|
||||||
right: -10,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
mounted: () => onChartReady(index),
|
|
||||||
updated: () => onChartReady(index),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stroke: { curve: 'smooth', width: 3 },
|
|
||||||
fill: {
|
|
||||||
type: 'gradient',
|
|
||||||
gradient: {
|
|
||||||
shadeIntensity: 1,
|
|
||||||
opacityFrom: 0.25,
|
|
||||||
opacityTo: 0.05,
|
|
||||||
stops: [0, 100],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: { enabled: false },
|
|
||||||
grid: { show: false },
|
|
||||||
xaxis: {
|
|
||||||
labels: { show: false },
|
|
||||||
axisBorder: { show: false },
|
|
||||||
type: 'numeric',
|
|
||||||
tickAmount: 20,
|
|
||||||
range: 20,
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
show: false,
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
forceNiceScale: false,
|
|
||||||
},
|
|
||||||
colors: [hasWarning ? 'var(--color-orange)' : 'var(--color-brand)'],
|
|
||||||
dataLabels: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.data?.current,
|
|
||||||
(newStats) => {
|
|
||||||
if (newStats) {
|
|
||||||
stats.value = newStats
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.chart-space {
|
|
||||||
height: 142px;
|
|
||||||
width: calc(100% + 48px);
|
|
||||||
margin-left: -24px;
|
|
||||||
margin-right: -24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 142px !important;
|
|
||||||
transition: opacity 0.3s ease-out;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div data-pyro-telepopover-wrapper class="relative">
|
|
||||||
<button
|
|
||||||
ref="triggerRef"
|
|
||||||
class="teleport-overflow-menu-trigger"
|
|
||||||
:aria-expanded="isOpen"
|
|
||||||
:aria-haspopup="true"
|
|
||||||
@mousedown="handleMouseDown"
|
|
||||||
@mouseenter="handleMouseEnter"
|
|
||||||
@mouseleave="handleMouseLeave"
|
|
||||||
@click="toggleMenu"
|
|
||||||
>
|
|
||||||
<slot></slot>
|
|
||||||
</button>
|
|
||||||
<Teleport to="#teleports">
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition duration-125 ease-out"
|
|
||||||
enter-from-class="transform scale-75 opacity-0"
|
|
||||||
enter-to-class="transform scale-100 opacity-100"
|
|
||||||
leave-active-class="transition duration-125 ease-in"
|
|
||||||
leave-from-class="transform scale-100 opacity-100"
|
|
||||||
leave-to-class="transform scale-75 opacity-0"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="isOpen"
|
|
||||||
ref="menuRef"
|
|
||||||
data-pyro-telepopover-root
|
|
||||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
|
||||||
:style="menuStyle"
|
|
||||||
role="menu"
|
|
||||||
tabindex="-1"
|
|
||||||
@mousedown.stop
|
|
||||||
@mouseleave="handleMouseLeave"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
v-for="(option, index) in filteredOptions"
|
|
||||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
|
||||||
>
|
|
||||||
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
|
||||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
|
||||||
<button
|
|
||||||
v-if="typeof option.action === 'function'"
|
|
||||||
:ref="
|
|
||||||
(el) => {
|
|
||||||
if (el) menuItemsRef[index] = el as HTMLElement
|
|
||||||
}
|
|
||||||
"
|
|
||||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
|
||||||
:aria-selected="index === selectedIndex"
|
|
||||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
|
||||||
@click="handleItemClick(option, index)"
|
|
||||||
@focus="selectedIndex = index"
|
|
||||||
@mouseover="handleMouseOver(index)"
|
|
||||||
>
|
|
||||||
<slot :name="option.id">{{ option.id }}</slot>
|
|
||||||
</button>
|
|
||||||
<nuxt-link
|
|
||||||
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
|
||||||
:ref="
|
|
||||||
(el) => {
|
|
||||||
if (el) menuItemsRef[index] = el as HTMLElement
|
|
||||||
}
|
|
||||||
"
|
|
||||||
:to="option.action"
|
|
||||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
|
||||||
:aria-selected="index === selectedIndex"
|
|
||||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
|
||||||
@click="handleItemClick(option, index)"
|
|
||||||
@focus="selectedIndex = index"
|
|
||||||
@mouseover="handleMouseOver(index)"
|
|
||||||
>
|
|
||||||
<slot :name="option.id">{{ option.id }}</slot>
|
|
||||||
</nuxt-link>
|
|
||||||
<a
|
|
||||||
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
|
||||||
:ref="
|
|
||||||
(el) => {
|
|
||||||
if (el) menuItemsRef[index] = el as HTMLElement
|
|
||||||
}
|
|
||||||
"
|
|
||||||
:href="option.action"
|
|
||||||
target="_blank"
|
|
||||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
|
||||||
:aria-selected="index === selectedIndex"
|
|
||||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
|
||||||
@click="handleItemClick(option, index)"
|
|
||||||
@focus="selectedIndex = index"
|
|
||||||
@mouseover="handleMouseOver(index)"
|
|
||||||
>
|
|
||||||
<slot :name="option.id">{{ option.id }}</slot>
|
|
||||||
</a>
|
|
||||||
<span v-else>
|
|
||||||
<slot :name="option.id">{{ option.id }}</slot>
|
|
||||||
</span>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ButtonStyled } from '@modrinth/ui'
|
|
||||||
import { onClickOutside, useElementHover } from '@vueuse/core'
|
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
interface Option {
|
|
||||||
id: string
|
|
||||||
action?: (() => void) | string
|
|
||||||
shown?: boolean
|
|
||||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
|
||||||
}
|
|
||||||
|
|
||||||
type Divider = {
|
|
||||||
divider: true
|
|
||||||
shown?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type Item = Option | Divider
|
|
||||||
|
|
||||||
function isDivider(item: Item): item is Divider {
|
|
||||||
return (item as Divider).divider
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
options: Item[]
|
|
||||||
hoverable?: boolean
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
hoverable: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'select', option: Option): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isOpen = ref(false)
|
|
||||||
const selectedIndex = ref(-1)
|
|
||||||
const menuRef = ref<HTMLElement | null>(null)
|
|
||||||
const triggerRef = ref<HTMLElement | null>(null)
|
|
||||||
const isMouseDown = ref(false)
|
|
||||||
const typeAheadBuffer = ref('')
|
|
||||||
const typeAheadTimeout = ref<number | null>(null)
|
|
||||||
const menuItemsRef = ref<HTMLElement[]>([])
|
|
||||||
|
|
||||||
const hoveringTrigger = useElementHover(triggerRef)
|
|
||||||
const hoveringMenu = useElementHover(menuRef)
|
|
||||||
|
|
||||||
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
|
|
||||||
|
|
||||||
const menuStyle = ref({
|
|
||||||
top: '0px',
|
|
||||||
left: '0px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
|
|
||||||
|
|
||||||
const calculateMenuPosition = () => {
|
|
||||||
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
|
|
||||||
|
|
||||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
|
||||||
const menuRect = menuRef.value.getBoundingClientRect()
|
|
||||||
const menuWidth = menuRect.width
|
|
||||||
const menuHeight = menuRect.height
|
|
||||||
const margin = 8
|
|
||||||
|
|
||||||
let top: number
|
|
||||||
let left: number
|
|
||||||
|
|
||||||
// okay gang lets calculate this shit
|
|
||||||
// from the top now yall
|
|
||||||
// y
|
|
||||||
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
|
|
||||||
top = triggerRect.bottom + margin
|
|
||||||
} else if (triggerRect.top - menuHeight - margin >= 0) {
|
|
||||||
top = triggerRect.top - menuHeight - margin
|
|
||||||
} else {
|
|
||||||
top = Math.max(margin, window.innerHeight - menuHeight - margin)
|
|
||||||
}
|
|
||||||
|
|
||||||
// x
|
|
||||||
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
|
|
||||||
left = triggerRect.left
|
|
||||||
} else if (triggerRect.right - menuWidth - margin >= 0) {
|
|
||||||
left = triggerRect.right - menuWidth
|
|
||||||
} else {
|
|
||||||
left = Math.max(margin, window.innerWidth - menuWidth - margin)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: `${top}px`,
|
|
||||||
left: `${left}px`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleMenu = (event: MouseEvent) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (!props.hoverable) {
|
|
||||||
if (isOpen.value) {
|
|
||||||
closeMenu()
|
|
||||||
} else {
|
|
||||||
openMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMenu = () => {
|
|
||||||
isOpen.value = true
|
|
||||||
disableBodyScroll()
|
|
||||||
nextTick(() => {
|
|
||||||
menuStyle.value = calculateMenuPosition()
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
|
||||||
focusFirstMenuItem()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeMenu = () => {
|
|
||||||
isOpen.value = false
|
|
||||||
selectedIndex.value = -1
|
|
||||||
enableBodyScroll()
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectOption = (option: Option) => {
|
|
||||||
emit('select', option)
|
|
||||||
if (typeof option.action === 'function') {
|
|
||||||
option.action()
|
|
||||||
}
|
|
||||||
closeMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseDown = (event: MouseEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
isMouseDown.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
if (props.hoverable) {
|
|
||||||
openMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
if (props.hoverable) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!hovering.value) {
|
|
||||||
closeMenu()
|
|
||||||
}
|
|
||||||
}, 250)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseMove = (event: MouseEvent) => {
|
|
||||||
if (!isOpen.value || !isMouseDown.value) return
|
|
||||||
|
|
||||||
const menuRect = menuRef.value?.getBoundingClientRect()
|
|
||||||
if (!menuRect) return
|
|
||||||
|
|
||||||
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
|
|
||||||
if (!menuItems) return
|
|
||||||
|
|
||||||
for (let i = 0; i < menuItems.length; i++) {
|
|
||||||
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
|
|
||||||
if (
|
|
||||||
event.clientX >= itemRect.left &&
|
|
||||||
event.clientX <= itemRect.right &&
|
|
||||||
event.clientY >= itemRect.top &&
|
|
||||||
event.clientY <= itemRect.bottom
|
|
||||||
) {
|
|
||||||
selectedIndex.value = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemClick = (option: Option, index: number) => {
|
|
||||||
selectedIndex.value = index
|
|
||||||
selectOption(option)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseOver = (index: number) => {
|
|
||||||
selectedIndex.value = index
|
|
||||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrolling is disabled for keyboard navigation
|
|
||||||
const disableBodyScroll = () => {
|
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
}
|
|
||||||
|
|
||||||
const enableBodyScroll = () => {
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusFirstMenuItem = () => {
|
|
||||||
if (menuItemsRef.value.length > 0) {
|
|
||||||
menuItemsRef.value[0].focus?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
|
||||||
if (!isOpen.value) {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault()
|
|
||||||
openMenu()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
event.preventDefault()
|
|
||||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
|
||||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
|
||||||
break
|
|
||||||
case 'ArrowUp':
|
|
||||||
event.preventDefault()
|
|
||||||
selectedIndex.value =
|
|
||||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
|
||||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
|
||||||
break
|
|
||||||
case 'Home':
|
|
||||||
event.preventDefault()
|
|
||||||
if (menuItemsRef.value.length > 0) {
|
|
||||||
selectedIndex.value = 0
|
|
||||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'End':
|
|
||||||
event.preventDefault()
|
|
||||||
if (menuItemsRef.value.length > 0) {
|
|
||||||
selectedIndex.value = filteredOptions.value.length - 1
|
|
||||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'Enter':
|
|
||||||
case ' ':
|
|
||||||
event.preventDefault()
|
|
||||||
if (selectedIndex.value >= 0) {
|
|
||||||
const option = filteredOptions.value[selectedIndex.value]
|
|
||||||
if (isDivider(option)) break
|
|
||||||
selectOption(option)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
event.preventDefault()
|
|
||||||
closeMenu()
|
|
||||||
triggerRef.value?.focus?.()
|
|
||||||
break
|
|
||||||
case 'Tab':
|
|
||||||
event.preventDefault()
|
|
||||||
if (menuItemsRef.value.length > 0) {
|
|
||||||
if (event.shiftKey) {
|
|
||||||
selectedIndex.value =
|
|
||||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
|
||||||
} else {
|
|
||||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
|
||||||
}
|
|
||||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
if (event.key.length === 1) {
|
|
||||||
typeAheadBuffer.value += event.key.toLowerCase()
|
|
||||||
const matchIndex = filteredOptions.value.findIndex(
|
|
||||||
(option) =>
|
|
||||||
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
|
||||||
)
|
|
||||||
if (matchIndex !== -1) {
|
|
||||||
selectedIndex.value = matchIndex
|
|
||||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
|
||||||
}
|
|
||||||
if (typeAheadTimeout.value) {
|
|
||||||
clearTimeout(typeAheadTimeout.value)
|
|
||||||
}
|
|
||||||
typeAheadTimeout.value = setTimeout(() => {
|
|
||||||
typeAheadBuffer.value = ''
|
|
||||||
}, 1000) as unknown as number
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResizeOrScroll = () => {
|
|
||||||
if (isOpen.value) {
|
|
||||||
menuStyle.value = calculateMenuPosition()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const throttle = (func: (...args: any[]) => void, limit: number): ((...args: any[]) => void) => {
|
|
||||||
let inThrottle: boolean
|
|
||||||
return function (...args: any[]) {
|
|
||||||
if (!inThrottle) {
|
|
||||||
func(...args)
|
|
||||||
inThrottle = true
|
|
||||||
setTimeout(() => (inThrottle = false), limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
triggerRef.value?.addEventListener('keydown', handleKeydown)
|
|
||||||
window.addEventListener('resize', throttledHandleResizeOrScroll)
|
|
||||||
window.addEventListener('scroll', throttledHandleResizeOrScroll)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
triggerRef.value?.removeEventListener('keydown', handleKeydown)
|
|
||||||
window.removeEventListener('resize', throttledHandleResizeOrScroll)
|
|
||||||
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
|
||||||
if (typeAheadTimeout.value) {
|
|
||||||
clearTimeout(typeAheadTimeout.value)
|
|
||||||
}
|
|
||||||
enableBodyScroll()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(isOpen, (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
nextTick(() => {
|
|
||||||
menuRef.value?.addEventListener('keydown', handleKeydown)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
menuRef.value?.removeEventListener('keydown', handleKeydown)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onClickOutside(menuRef, (event) => {
|
|
||||||
if (!triggerRef.value?.contains(event.target as Node)) {
|
|
||||||
closeMenu()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="lucide lucide-chevron-down"
|
|
||||||
>
|
|
||||||
<path d="m6 9 6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="lucide lucide-chevron-up"
|
|
||||||
>
|
|
||||||
<path d="m18 15-6-6-6 6" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M10 12.5 8 15l2 2.5" />
|
|
||||||
<path d="m14 12.5 2 2.5-2 2.5" />
|
|
||||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
||||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="18" cy="18" r="3" />
|
|
||||||
<path
|
|
||||||
d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v3.3"
|
|
||||||
/>
|
|
||||||
<path d="m21.7 19.4-.9-.3" />
|
|
||||||
<path d="m15.2 16.9-.9-.3" />
|
|
||||||
<path d="m16.6 21.7.3-.9" />
|
|
||||||
<path d="m19.1 15.2.3-.9" />
|
|
||||||
<path d="m19.6 21.7-.4-1" />
|
|
||||||
<path d="m16.8 15.3-.4-1" />
|
|
||||||
<path d="m14.3 19.6 1-.4" />
|
|
||||||
<path d="m20.7 16.8 1-.4" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
|
|
||||||
<path
|
|
||||||
d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"
|
|
||||||
/>
|
|
||||||
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="size-5"
|
|
||||||
>
|
|
||||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
|
||||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
|
||||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
|
||||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
|
||||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
||||||
<circle cx="10" cy="12" r="2" />
|
|
||||||
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<template>
|
|
||||||
<component :is="icon" v-if="icon" />
|
|
||||||
<LoaderIcon v-else />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { getLoaderIcon, LoaderIcon } from '@modrinth/assets'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
loader: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const icon = computed(() => getLoaderIcon(props.loader))
|
|
||||||
</script>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="size-5"
|
|
||||||
>
|
|
||||||
<path d="m15 15 6 6m-6-6v4.8m0-4.8h4.8" />
|
|
||||||
<path d="M9 19.8V15m0 0H4.2M9 15l-6 6" />
|
|
||||||
<path d="M15 4.2V9m0 0h4.8M15 9l6-6" />
|
|
||||||
<path d="M9 4.2V9m0 0H4.2M9 9 3 3" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="size-8 text-[#FF496E]"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg height="32" viewBox="0 0 32 32" width="32">
|
|
||||||
<path
|
|
||||||
d="M22 5L9 28"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="lucide lucide-file-text"
|
|
||||||
>
|
|
||||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
|
||||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
||||||
<path d="M10 9H8" />
|
|
||||||
<path d="M16 13H8" />
|
|
||||||
<path d="M16 17H8" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="10" x2="14" y1="2" y2="2" />
|
|
||||||
<line x1="12" x2="15" y1="14" y2="11" />
|
|
||||||
<circle cx="12" cy="14" r="8" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { SettingsIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
CopyCode,
|
|
||||||
getDismissableMetadata,
|
|
||||||
NOTICE_LEVELS,
|
|
||||||
ServerNotice,
|
|
||||||
TagItem,
|
|
||||||
useFormatDateTime,
|
|
||||||
useRelativeTime,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const formatRelativeTime = useRelativeTime()
|
|
||||||
const formatDateTime = useFormatDateTime({
|
|
||||||
timeStyle: 'short',
|
|
||||||
dateStyle: 'long',
|
|
||||||
})
|
|
||||||
const formatDateTimeShortMonth = useFormatDateTime({
|
|
||||||
timeStyle: 'short',
|
|
||||||
dateStyle: 'medium',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
notice: ServerNoticeType
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<div class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4">
|
|
||||||
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<CopyCode :text="`${notice.id}`" />
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
<span v-if="notice.announce_at">
|
|
||||||
{{ formatDateTimeShortMonth(notice.announce_at) }} ({{
|
|
||||||
formatRelativeTime(notice.announce_at)
|
|
||||||
}})
|
|
||||||
</span>
|
|
||||||
<template v-else> Never begins </template>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
<span v-if="notice.expires" v-tooltip="formatDateTime(notice.expires)">
|
|
||||||
{{ formatRelativeTime(notice.expires) }}
|
|
||||||
</span>
|
|
||||||
<template v-else> Never expires </template>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:style="
|
|
||||||
NOTICE_LEVELS[notice.level]
|
|
||||||
? {
|
|
||||||
'--_color': NOTICE_LEVELS[notice.level].colors.text,
|
|
||||||
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<TagItem>
|
|
||||||
{{
|
|
||||||
NOTICE_LEVELS[notice.level]
|
|
||||||
? formatMessage(NOTICE_LEVELS[notice.level].name)
|
|
||||||
: notice.level
|
|
||||||
}}
|
|
||||||
</TagItem>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:style="{
|
|
||||||
'--_color': getDismissableMetadata(notice.dismissable).colors.text,
|
|
||||||
'--_bg-color': getDismissableMetadata(notice.dismissable).colors.bg,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<TagItem>
|
|
||||||
{{ formatMessage(getDismissableMetadata(notice.dismissable).name) }}
|
|
||||||
</TagItem>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex gap-2 md:col-span-1">
|
|
||||||
<!-- <ButtonStyled>
|
|
||||||
<button @click="() => startEditing(notice)">
|
|
||||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="red">
|
|
||||||
<button @click="() => deleteNotice(notice)">
|
|
||||||
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-full grid">
|
|
||||||
<ServerNotice
|
|
||||||
:level="notice.level"
|
|
||||||
:message="notice.message"
|
|
||||||
:dismissable="notice.dismissable"
|
|
||||||
:title="notice.title"
|
|
||||||
preview
|
|
||||||
/>
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<span v-if="!notice.assigned || notice.assigned.length === 0"
|
|
||||||
>Not assigned to any servers</span
|
|
||||||
>
|
|
||||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
|
|
||||||
Assigned to
|
|
||||||
{{ notice.assigned.filter((n) => n.kind === 'node').length }} nodes
|
|
||||||
</span>
|
|
||||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
|
|
||||||
Assigned to
|
|
||||||
{{ notice.assigned.filter((n) => n.kind === 'server').length }} servers
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
Assigned to
|
|
||||||
{{ notice.assigned.filter((n) => n.kind === 'server').length }} servers and
|
|
||||||
{{ notice.assigned.filter((n) => n.kind === 'node').length }} nodes
|
|
||||||
</span>
|
|
||||||
•
|
|
||||||
<button
|
|
||||||
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
|
|
||||||
@click="() => startEditing(notice, true)"
|
|
||||||
>
|
|
||||||
<SettingsIcon />
|
|
||||||
Edit assignments
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" generic="T">
|
<script setup lang="ts" generic="T">
|
||||||
import { MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
|
import { ChevronDownIcon, MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
|
||||||
import type { QuickReply } from '@modrinth/moderation'
|
import type { QuickReply } from '@modrinth/moderation'
|
||||||
import {
|
import {
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
@@ -90,7 +90,6 @@ import dayjs from 'dayjs'
|
|||||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||||
import { isStaff } from '~/helpers/users.js'
|
import { isStaff } from '~/helpers/users.js'
|
||||||
|
|
||||||
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
|
|
||||||
import ThreadMessage from './ThreadMessage.vue'
|
import ThreadMessage from './ThreadMessage.vue'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import type { Archon } from '@modrinth/api-client'
|
|
||||||
import { injectModrinthClient } from '@modrinth/ui'
|
|
||||||
import { type ComputedRef, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
// TODO: Remove and use V1 when available
|
|
||||||
export function useServerImage(
|
|
||||||
serverId: string,
|
|
||||||
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
|
|
||||||
) {
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const image = ref<string | undefined>()
|
|
||||||
|
|
||||||
const sharedImage = useState<string | undefined>(`server-icon-${serverId}`)
|
|
||||||
if (sharedImage.value) {
|
|
||||||
image.value = sharedImage.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadImage() {
|
|
||||||
if (sharedImage.value) {
|
|
||||||
image.value = sharedImage.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.server) return
|
|
||||||
|
|
||||||
const cached = localStorage.getItem(`server-icon-${serverId}`)
|
|
||||||
if (cached) {
|
|
||||||
sharedImage.value = cached
|
|
||||||
image.value = cached
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let projectIconUrl: string | undefined
|
|
||||||
const upstreamVal = upstream.value
|
|
||||||
if (upstreamVal?.project_id) {
|
|
||||||
try {
|
|
||||||
const project = await $fetch<{ icon_url?: string }>(
|
|
||||||
`https://api.modrinth.com/v2/project/${upstreamVal.project_id}`,
|
|
||||||
)
|
|
||||||
projectIconUrl = project.icon_url
|
|
||||||
} catch {
|
|
||||||
// project fetch failed, continue without icon url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png')
|
|
||||||
|
|
||||||
if (fileData instanceof Blob) {
|
|
||||||
const dataURL = await resizeImage(fileData, 512)
|
|
||||||
sharedImage.value = dataURL
|
|
||||||
localStorage.setItem(`server-icon-${serverId}`, dataURL)
|
|
||||||
image.value = dataURL
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.statusCode >= 500) {
|
|
||||||
image.value = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error?.statusCode === 404 && projectIconUrl) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(projectIconUrl)
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch icon')
|
|
||||||
const file = await response.blob()
|
|
||||||
const originalFile = new File([file], 'server-icon-original.png', {
|
|
||||||
type: 'image/png',
|
|
||||||
})
|
|
||||||
|
|
||||||
const dataURL = await new Promise<string>((resolve) => {
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
const img = new Image()
|
|
||||||
img.onload = () => {
|
|
||||||
canvas.width = 64
|
|
||||||
canvas.height = 64
|
|
||||||
ctx?.drawImage(img, 0, 0, 64, 64)
|
|
||||||
canvas.toBlob(async (blob) => {
|
|
||||||
if (blob) {
|
|
||||||
const scaledFile = new File([blob], 'server-icon.png', {
|
|
||||||
type: 'image/png',
|
|
||||||
})
|
|
||||||
client.kyros.files_v0
|
|
||||||
.uploadFile('/server-icon.png', scaledFile)
|
|
||||||
.promise.catch(() => {})
|
|
||||||
client.kyros.files_v0
|
|
||||||
.uploadFile('/server-icon-original.png', originalFile)
|
|
||||||
.promise.catch(() => {})
|
|
||||||
}
|
|
||||||
}, 'image/png')
|
|
||||||
const result = canvas.toDataURL('image/png')
|
|
||||||
sharedImage.value = result
|
|
||||||
localStorage.setItem(`server-icon-${serverId}`, result)
|
|
||||||
resolve(result)
|
|
||||||
URL.revokeObjectURL(img.src)
|
|
||||||
}
|
|
||||||
img.src = URL.createObjectURL(file)
|
|
||||||
})
|
|
||||||
image.value = dataURL
|
|
||||||
return
|
|
||||||
} catch (externalError: any) {
|
|
||||||
console.debug('Could not process external icon:', externalError.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
image.value = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(upstream, () => loadImage(), { immediate: true })
|
|
||||||
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
function resizeImage(blob: Blob, size: number): Promise<string> {
|
|
||||||
return new Promise<string>((resolve) => {
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
const img = new Image()
|
|
||||||
img.onload = () => {
|
|
||||||
canvas.width = size
|
|
||||||
canvas.height = size
|
|
||||||
ctx?.drawImage(img, 0, 0, size, size)
|
|
||||||
const dataURL = canvas.toDataURL('image/png')
|
|
||||||
resolve(dataURL)
|
|
||||||
URL.revokeObjectURL(img.src)
|
|
||||||
}
|
|
||||||
img.src = URL.createObjectURL(blob)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -83,6 +83,7 @@ provideModrinthClient(client)
|
|||||||
providePageContext({
|
providePageContext({
|
||||||
hierarchicalSidebarAvailable: ref(false),
|
hierarchicalSidebarAvailable: ref(false),
|
||||||
showAds: ref(false),
|
showAds: ref(false),
|
||||||
|
openExternalUrl: (url) => window.open(url, '_blank'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
@@ -281,7 +281,7 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<nuxt-link to="/hosting">
|
<nuxt-link to="/hosting">
|
||||||
<ServerIcon aria-hidden="true" />
|
<ServerStackIcon aria-hidden="true" />
|
||||||
{{ formatMessage(navMenuMessages.hostAServer) }}
|
{{ formatMessage(navMenuMessages.hostAServer) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -463,7 +463,7 @@
|
|||||||
<LibraryIcon aria-hidden="true" /> {{ formatMessage(commonMessages.collectionsLabel) }}
|
<LibraryIcon aria-hidden="true" /> {{ formatMessage(commonMessages.collectionsLabel) }}
|
||||||
</template>
|
</template>
|
||||||
<template #servers>
|
<template #servers>
|
||||||
<ServerIcon aria-hidden="true" /> {{ formatMessage(messages.myServers) }}
|
<ServerStackIcon aria-hidden="true" /> {{ formatMessage(messages.myServers) }}
|
||||||
</template>
|
</template>
|
||||||
<template #plus>
|
<template #plus>
|
||||||
<ArrowBigUpDashIcon aria-hidden="true" />
|
<ArrowBigUpDashIcon aria-hidden="true" />
|
||||||
@@ -722,6 +722,7 @@ import {
|
|||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
ServerStackIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
ShieldAlertIcon,
|
ShieldAlertIcon,
|
||||||
SunIcon,
|
SunIcon,
|
||||||
@@ -742,6 +743,7 @@ import {
|
|||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import TeleportOverflowMenu from '@modrinth/ui/src/components/base/TeleportOverflowMenu.vue'
|
||||||
import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
|
import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
|
||||||
@@ -760,7 +762,6 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
|
|||||||
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
|
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
|
||||||
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
|
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
|
||||||
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
|
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
|
||||||
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
|
|
||||||
import { getSignInRouteObj } from '~/composables/auth.js'
|
import { getSignInRouteObj } from '~/composables/auth.js'
|
||||||
import { errors as generatedStateErrors } from '~/generated/state.json'
|
import { errors as generatedStateErrors } from '~/generated/state.json'
|
||||||
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
|
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
|
||||||
|
|||||||
@@ -1301,60 +1301,6 @@
|
|||||||
"hosting-marketing.why.your-favorite-mods.description": {
|
"hosting-marketing.why.your-favorite-mods.description": {
|
||||||
"message": "Choose between Vanilla, Fabric, Forge, Quilt and NeoForge. If it's on Modrinth, it can run on your server."
|
"message": "Choose between Vanilla, Fabric, Forge, Quilt and NeoForge. If it's on Modrinth, it can run on your server."
|
||||||
},
|
},
|
||||||
"hosting.loader.failed-to-change-version": {
|
|
||||||
"message": "Failed to change modpack version"
|
|
||||||
},
|
|
||||||
"hosting.loader.failed-to-load-versions": {
|
|
||||||
"message": "Failed to load versions"
|
|
||||||
},
|
|
||||||
"hosting.loader.failed-to-reinstall": {
|
|
||||||
"message": "Failed to reinstall modpack"
|
|
||||||
},
|
|
||||||
"hosting.loader.failed-to-repair": {
|
|
||||||
"message": "Failed to repair server"
|
|
||||||
},
|
|
||||||
"hosting.loader.failed-to-reset-to-onboarding": {
|
|
||||||
"message": "Failed to reset server to onboarding"
|
|
||||||
},
|
|
||||||
"hosting.loader.failed-to-save-settings": {
|
|
||||||
"message": "Failed to save installation settings"
|
|
||||||
},
|
|
||||||
"hosting.loader.failed-to-unlink": {
|
|
||||||
"message": "Failed to unlink modpack"
|
|
||||||
},
|
|
||||||
"hosting.loader.loader-version": {
|
|
||||||
"message": "{loader, select, null {Loader} other {{loader}}} version"
|
|
||||||
},
|
|
||||||
"hosting.loader.repair-started-text": {
|
|
||||||
"message": "Your server installation has been repaired."
|
|
||||||
},
|
|
||||||
"hosting.loader.repair-started-title": {
|
|
||||||
"message": "Repair completed"
|
|
||||||
},
|
|
||||||
"hosting.loader.reset-server": {
|
|
||||||
"message": "Reset server"
|
|
||||||
},
|
|
||||||
"hosting.loader.reset-server-description": {
|
|
||||||
"message": "Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored."
|
|
||||||
},
|
|
||||||
"hosting.loader.reset-to-onboarding-button": {
|
|
||||||
"message": "Reset to onboarding"
|
|
||||||
},
|
|
||||||
"hosting.loader.reset-to-onboarding-modal-description": {
|
|
||||||
"message": "This will send the server back into onboarding so setup can be completed again. Are you sure you want to continue?"
|
|
||||||
},
|
|
||||||
"hosting.loader.reset-to-onboarding-modal-title": {
|
|
||||||
"message": "Reset to onboarding"
|
|
||||||
},
|
|
||||||
"hosting.loader.reset-to-onboarding-success-description": {
|
|
||||||
"message": "The server has been returned to the onboarding flow."
|
|
||||||
},
|
|
||||||
"hosting.loader.reset-to-onboarding-success-title": {
|
|
||||||
"message": "Server reset to onboarding"
|
|
||||||
},
|
|
||||||
"hosting.loader.support-options-title": {
|
|
||||||
"message": "Support options"
|
|
||||||
},
|
|
||||||
"hosting.plan.out-of-stock": {
|
"hosting.plan.out-of-stock": {
|
||||||
"message": "Out of stock"
|
"message": "Out of stock"
|
||||||
},
|
},
|
||||||
@@ -2831,18 +2777,6 @@
|
|||||||
"search.filter.locked.server.sync": {
|
"search.filter.locked.server.sync": {
|
||||||
"message": "Sync with server"
|
"message": "Sync with server"
|
||||||
},
|
},
|
||||||
"servers.busy.backup-creating": {
|
|
||||||
"message": "Backup creation in progress"
|
|
||||||
},
|
|
||||||
"servers.busy.backup-restoring": {
|
|
||||||
"message": "Backup restore in progress"
|
|
||||||
},
|
|
||||||
"servers.busy.installing": {
|
|
||||||
"message": "Server is installing"
|
|
||||||
},
|
|
||||||
"servers.busy.syncing-content": {
|
|
||||||
"message": "Content sync in progress"
|
|
||||||
},
|
|
||||||
"servers.notice.actions": {
|
"servers.notice.actions": {
|
||||||
"message": "Actions"
|
"message": "Actions"
|
||||||
},
|
},
|
||||||
@@ -3221,6 +3155,12 @@
|
|||||||
"settings.billing.interval.monthly": {
|
"settings.billing.interval.monthly": {
|
||||||
"message": "monthly"
|
"message": "monthly"
|
||||||
},
|
},
|
||||||
|
"settings.billing.interval.quarter": {
|
||||||
|
"message": "quarter"
|
||||||
|
},
|
||||||
|
"settings.billing.interval.quarterly.adjective": {
|
||||||
|
"message": "quarterly"
|
||||||
|
},
|
||||||
"settings.billing.interval.year": {
|
"settings.billing.interval.year": {
|
||||||
"message": "year"
|
"message": "year"
|
||||||
},
|
},
|
||||||
@@ -3336,7 +3276,7 @@
|
|||||||
"message": "Error resubscribing"
|
"message": "Error resubscribing"
|
||||||
},
|
},
|
||||||
"settings.billing.pyro.resubscribe.request-submitted.text": {
|
"settings.billing.pyro.resubscribe.request-submitted.text": {
|
||||||
"message": "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made."
|
"message": "If the server is currently cancelled, it may take 10-15 minutes to set up the server."
|
||||||
},
|
},
|
||||||
"settings.billing.pyro.resubscribe.request-submitted.title": {
|
"settings.billing.pyro.resubscribe.request-submitted.title": {
|
||||||
"message": "Resubscription request submitted"
|
"message": "Resubscription request submitted"
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
|
|||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
|
import ModrinthServersIcon from '~/components/brand/ModrinthServersIcon.vue'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const { labrinth } = injectModrinthClient()
|
const { labrinth } = injectModrinthClient()
|
||||||
@@ -519,7 +519,7 @@ async function modifyCharge() {
|
|||||||
})
|
})
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Modifications made',
|
title: 'Modifications made',
|
||||||
text: 'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
|
text: 'If the server is currently cancelled, it may take up to 10 minutes for another charge attempt to be made.',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
})
|
})
|
||||||
await refreshCharges()
|
await refreshCharges()
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import AssignNoticeModal from '~/components/ui/servers/notice/AssignNoticeModal.vue'
|
import AssignNoticeModal from '~/components/ui/admin/AssignNoticeModal.vue'
|
||||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const selectableProjectTypes = [
|
|||||||
<template>
|
<template>
|
||||||
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
|
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
|
||||||
<section class="normal-page__header mb-4 flex flex-col gap-4">
|
<section class="normal-page__header mb-4 flex flex-col gap-4">
|
||||||
<div id="discover-header-prefix" class="empty:hidden"></div>
|
|
||||||
<NavTabs
|
<NavTabs
|
||||||
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
|
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
|
||||||
:links="selectableProjectTypes"
|
:links="selectableProjectTypes"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -643,6 +643,7 @@ import {
|
|||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
IntlFormatted,
|
IntlFormatted,
|
||||||
|
LoaderIcon,
|
||||||
ModrinthServersPurchaseModal,
|
ModrinthServersPurchaseModal,
|
||||||
useFormatPrice,
|
useFormatPrice,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
@@ -652,7 +653,6 @@ import { useQuery } from '@tanstack/vue-query'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import OptionGroup from '~/components/ui/OptionGroup.vue'
|
import OptionGroup from '~/components/ui/OptionGroup.vue'
|
||||||
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
|
|
||||||
import MedalPlanPromotion from '~/components/ui/servers/marketing/MedalPlanPromotion.vue'
|
import MedalPlanPromotion from '~/components/ui/servers/marketing/MedalPlanPromotion.vue'
|
||||||
import ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSelector.vue'
|
import ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSelector.vue'
|
||||||
import { products } from '~/generated/state.json'
|
import { products } from '~/generated/state.json'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,728 +1,13 @@
|
|||||||
<template>
|
|
||||||
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
|
|
||||||
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
|
|
||||||
Your server is still accessible during this time.
|
|
||||||
</Admonition>
|
|
||||||
<Admonition
|
|
||||||
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
|
|
||||||
data-pyro-servers-inspecting-error
|
|
||||||
type="critical"
|
|
||||||
:header="`${serverData?.name} shut down unexpectedly.`"
|
|
||||||
dismissible
|
|
||||||
@dismiss="clearError"
|
|
||||||
>
|
|
||||||
<template v-if="inspectingError.analysis.problems.length">
|
|
||||||
<p class="m-0 text-sm opacity-80">
|
|
||||||
We automatically analyzed the logs and found the following:
|
|
||||||
</p>
|
|
||||||
<div class="mt-2 flex flex-col gap-2">
|
|
||||||
<div
|
|
||||||
v-for="problem in inspectingError.analysis.problems"
|
|
||||||
:key="problem.message"
|
|
||||||
class="bg-raised-bg/30 rounded-xl px-3 py-2"
|
|
||||||
>
|
|
||||||
<p class="m-0 text-sm font-semibold">{{ problem.message }}</p>
|
|
||||||
<ul v-if="problem.solutions.length" class="m-0 ml-4 mt-1.5 flex flex-col gap-1">
|
|
||||||
<li
|
|
||||||
v-for="solution in problem.solutions"
|
|
||||||
:key="solution.message"
|
|
||||||
class="text-sm opacity-80"
|
|
||||||
>
|
|
||||||
{{ solution.message }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="props.serverPowerState === 'crashed'">
|
|
||||||
<template v-if="props.powerStateDetails?.oom_killed">
|
|
||||||
The server stopped because it ran out of memory. There may be a memory leak caused by a
|
|
||||||
mod or plugin, or you may need to upgrade your Modrinth Server.
|
|
||||||
</template>
|
|
||||||
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
|
|
||||||
Your server exited with code {{ props.powerStateDetails.exit_code }}.
|
|
||||||
<template v-if="props.powerStateDetails.exit_code === 1">
|
|
||||||
There may be a mod or plugin causing the issue, or an issue with your server
|
|
||||||
configuration.
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template v-else> We could not determine the specific cause of the crash. </template>
|
|
||||||
<p class="m-0 mt-2">You can try restarting the server.</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
We could not find any specific problems, but you can try restarting the server.
|
|
||||||
</template>
|
|
||||||
</Admonition>
|
|
||||||
|
|
||||||
<div class="flex flex-col-reverse gap-6 md:flex-col">
|
|
||||||
<ServerStats
|
|
||||||
:data="isConnected && !isWsAuthIncorrect ? stats : undefined"
|
|
||||||
:loading="!isConnected || isWsAuthIncorrect"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
|
|
||||||
:class="{ 'border-0': !isConnected || isWsAuthIncorrect }"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
|
||||||
<PanelServerStatus v-if="isConnected && !isWsAuthIncorrect" :state="serverPowerState" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PanelTerminal :full-screen="fullScreen" :loading="!isConnected || isWsAuthIncorrect">
|
|
||||||
<div class="relative w-full px-4 pt-4">
|
|
||||||
<ul
|
|
||||||
v-if="suggestions.length && isConnected && !isWsAuthIncorrect"
|
|
||||||
id="command-suggestions"
|
|
||||||
ref="suggestionsList"
|
|
||||||
class="mt-1 max-h-60 w-full list-none overflow-auto rounded-md border border-divider bg-bg-raised p-0 shadow-lg"
|
|
||||||
role="listbox"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="(suggestion, index) in suggestions"
|
|
||||||
:id="'suggestion-' + index"
|
|
||||||
:key="index"
|
|
||||||
role="option"
|
|
||||||
:aria-selected="index === selectedSuggestionIndex"
|
|
||||||
:class="[
|
|
||||||
'cursor-pointer px-4 py-2',
|
|
||||||
index === selectedSuggestionIndex ? 'bg-bg-raised' : 'bg-bg',
|
|
||||||
]"
|
|
||||||
@click="selectSuggestion(index)"
|
|
||||||
@mousemove="() => (selectedSuggestionIndex = index)"
|
|
||||||
>
|
|
||||||
{{ suggestion }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="relative flex items-center">
|
|
||||||
<span
|
|
||||||
v-if="bestSuggestion && isConnected && !isWsAuthIncorrect"
|
|
||||||
class="pointer-events-none absolute left-[26px] transform select-none text-gray-400"
|
|
||||||
>
|
|
||||||
<span class="ml-[23.5px] whitespace-pre">{{
|
|
||||||
' '.repeat(commandInput.length - 1)
|
|
||||||
}}</span>
|
|
||||||
<span> {{ bestSuggestion }} </span>
|
|
||||||
<button
|
|
||||||
class="text pointer-events-auto ml-2 cursor-pointer rounded-md border-none bg-white text-sm focus:outline-none dark:bg-highlight"
|
|
||||||
aria-label="Accept suggestion"
|
|
||||||
style="transform: translateY(-1px)"
|
|
||||||
@click="acceptSuggestion"
|
|
||||||
>
|
|
||||||
TAB
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute left-0 top-0 flex h-full w-full items-center"
|
|
||||||
>
|
|
||||||
<TerminalSquareIcon class="ml-3 h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-if="isServerRunning && isConnected && !isWsAuthIncorrect"
|
|
||||||
v-model="commandInput"
|
|
||||||
type="text"
|
|
||||||
placeholder="Send a command"
|
|
||||||
class="w-full rounded-md !pl-10 pt-4 focus:border-none [&&]:border-[1px] [&&]:border-solid [&&]:border-bg-raised [&&]:bg-bg"
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="command-suggestions"
|
|
||||||
spellcheck="false"
|
|
||||||
:aria-activedescendant="'suggestion-' + selectedSuggestionIndex"
|
|
||||||
@keydown.tab.prevent="acceptSuggestion"
|
|
||||||
@keydown.down.prevent="selectNextSuggestion"
|
|
||||||
@keydown.up.prevent="selectPrevSuggestion"
|
|
||||||
@keydown.enter.prevent="sendCommand"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
disabled
|
|
||||||
type="text"
|
|
||||||
placeholder="Send a command"
|
|
||||||
class="w-full rounded-md !pl-10 focus:border-none [&&]:border-[1px] [&&]:border-solid [&&]:border-bg-raised [&&]:bg-bg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelTerminal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isWsAuthIncorrect"
|
|
||||||
class="absolute inset-0 flex flex-col items-center justify-center bg-bg"
|
|
||||||
>
|
|
||||||
<h2>Could not connect to the server.</h2>
|
|
||||||
<p>
|
|
||||||
An error occurred while attempting to connect to your server. Please try refreshing the
|
|
||||||
page. (WebSocket Authentication Failed)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TerminalSquareIcon } from '@modrinth/assets'
|
import { injectModrinthServerContext, ServersManageOverviewPage } from '@modrinth/ui'
|
||||||
import {
|
|
||||||
Admonition,
|
|
||||||
injectModrinthClient,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import type { ServerState, Stats } from '@modrinth/utils'
|
|
||||||
|
|
||||||
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
|
const { server } = injectModrinthServerContext()
|
||||||
import PanelTerminal from '~/components/ui/servers/PanelTerminal.vue'
|
|
||||||
import ServerStats from '~/components/ui/servers/ServerStats.vue'
|
|
||||||
|
|
||||||
type ServerProps = {
|
|
||||||
isConnected: boolean
|
|
||||||
isWsAuthIncorrect: boolean
|
|
||||||
stats: Stats
|
|
||||||
serverPowerState: ServerState
|
|
||||||
powerStateDetails?: {
|
|
||||||
oom_killed?: boolean
|
|
||||||
exit_code?: number
|
|
||||||
}
|
|
||||||
isServerRunning: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<ServerProps>()
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const { server: serverData, serverId, busyReasons } = injectModrinthServerContext()
|
|
||||||
|
|
||||||
const backupBusyReason = computed(() => {
|
|
||||||
const reason = busyReasons.value.find(
|
|
||||||
(r) =>
|
|
||||||
r.reason.id === 'servers.busy.backup-creating' ||
|
|
||||||
r.reason.id === 'servers.busy.backup-restoring',
|
|
||||||
)
|
|
||||||
return reason ? formatMessage(reason.reason) : null
|
|
||||||
})
|
|
||||||
|
|
||||||
interface ErrorData {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
version: string
|
|
||||||
title: string
|
|
||||||
analysis: {
|
|
||||||
problems: Array<{
|
|
||||||
message: string
|
|
||||||
counter: number
|
|
||||||
entry: {
|
|
||||||
level: number
|
|
||||||
time: string | null
|
|
||||||
prefix: string
|
|
||||||
lines: Array<{ number: number; content: string }>
|
|
||||||
}
|
|
||||||
solutions: Array<{ message: string }>
|
|
||||||
}>
|
|
||||||
information: Array<{
|
|
||||||
message: string
|
|
||||||
counter: number
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
entry: {
|
|
||||||
level: number
|
|
||||||
time: string | null
|
|
||||||
prefix: string
|
|
||||||
lines: Array<{ number: number; content: string }>
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inspectingError = ref<ErrorData | null>(null)
|
|
||||||
|
|
||||||
const inspectError = async () => {
|
|
||||||
try {
|
|
||||||
const blob = await client.kyros.files_v0.downloadFile('/logs/latest.log')
|
|
||||||
const log = await blob.text()
|
|
||||||
if (!log) return
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
content: log,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
|
|
||||||
inspectingError.value = response as ErrorData
|
|
||||||
} else {
|
|
||||||
inspectingError.value = null
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to analyze logs:', error)
|
|
||||||
inspectingError.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearError = () => {
|
|
||||||
inspectingError.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.serverPowerState,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal === 'crashed' && !props.powerStateDetails?.oom_killed) {
|
|
||||||
inspectError()
|
|
||||||
} else {
|
|
||||||
clearError()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (props.serverPowerState === 'crashed' && !props.powerStateDetails?.oom_killed) {
|
|
||||||
inspectError()
|
|
||||||
}
|
|
||||||
|
|
||||||
const DYNAMIC_ARG = Symbol('DYNAMIC_ARG')
|
|
||||||
|
|
||||||
const commandTree: any = {
|
|
||||||
advancement: {
|
|
||||||
grant: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
everything: null,
|
|
||||||
only: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
through: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
until: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
revoke: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
everything: null,
|
|
||||||
only: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
through: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
until: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ban: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
duration: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'ban-ip': null,
|
|
||||||
banlist: {
|
|
||||||
ips: null,
|
|
||||||
players: null,
|
|
||||||
all: null,
|
|
||||||
},
|
|
||||||
bossbar: {
|
|
||||||
add: null,
|
|
||||||
get: null,
|
|
||||||
list: null,
|
|
||||||
remove: null,
|
|
||||||
set: null,
|
|
||||||
},
|
|
||||||
clear: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
clone: null,
|
|
||||||
data: {
|
|
||||||
get: null,
|
|
||||||
merge: null,
|
|
||||||
modify: null,
|
|
||||||
remove: null,
|
|
||||||
},
|
|
||||||
datapack: {
|
|
||||||
disable: null,
|
|
||||||
enable: null,
|
|
||||||
list: null,
|
|
||||||
reload: null,
|
|
||||||
},
|
|
||||||
debug: {
|
|
||||||
start: null,
|
|
||||||
stop: null,
|
|
||||||
function: null,
|
|
||||||
memory: null,
|
|
||||||
},
|
|
||||||
defaultgamemode: {
|
|
||||||
survival: null,
|
|
||||||
creative: null,
|
|
||||||
adventure: null,
|
|
||||||
spectator: null,
|
|
||||||
},
|
|
||||||
deop: null,
|
|
||||||
difficulty: {
|
|
||||||
peaceful: null,
|
|
||||||
easy: null,
|
|
||||||
normal: null,
|
|
||||||
hard: null,
|
|
||||||
},
|
|
||||||
effect: {
|
|
||||||
give: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
true: null,
|
|
||||||
false: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
clear: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
enchant: null,
|
|
||||||
execute: null,
|
|
||||||
experience: {
|
|
||||||
add: null,
|
|
||||||
set: null,
|
|
||||||
query: null,
|
|
||||||
},
|
|
||||||
fill: null,
|
|
||||||
forceload: {
|
|
||||||
add: null,
|
|
||||||
remove: null,
|
|
||||||
query: null,
|
|
||||||
},
|
|
||||||
function: null,
|
|
||||||
gamemode: {
|
|
||||||
survival: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
creative: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
adventure: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
spectator: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
gamerule: null,
|
|
||||||
give: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
help: null,
|
|
||||||
kick: null,
|
|
||||||
kill: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
list: null,
|
|
||||||
locate: {
|
|
||||||
biome: null,
|
|
||||||
poi: null,
|
|
||||||
structure: null,
|
|
||||||
},
|
|
||||||
loot: {
|
|
||||||
give: null,
|
|
||||||
insert: null,
|
|
||||||
replace: null,
|
|
||||||
spawn: null,
|
|
||||||
},
|
|
||||||
me: null,
|
|
||||||
msg: null,
|
|
||||||
op: null,
|
|
||||||
pardon: null,
|
|
||||||
'pardon-ip': null,
|
|
||||||
particle: null,
|
|
||||||
playsound: null,
|
|
||||||
recipe: {
|
|
||||||
give: null,
|
|
||||||
take: null,
|
|
||||||
},
|
|
||||||
reload: null,
|
|
||||||
say: null,
|
|
||||||
schedule: {
|
|
||||||
function: null,
|
|
||||||
clear: null,
|
|
||||||
},
|
|
||||||
scoreboard: {
|
|
||||||
objectives: {
|
|
||||||
add: null,
|
|
||||||
remove: null,
|
|
||||||
setdisplay: null,
|
|
||||||
list: null,
|
|
||||||
modify: null,
|
|
||||||
},
|
|
||||||
players: {
|
|
||||||
add: null,
|
|
||||||
remove: null,
|
|
||||||
set: null,
|
|
||||||
get: null,
|
|
||||||
list: null,
|
|
||||||
enable: null,
|
|
||||||
operation: null,
|
|
||||||
reset: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seed: null,
|
|
||||||
setblock: null,
|
|
||||||
setidletimeout: null,
|
|
||||||
setworldspawn: null,
|
|
||||||
spawnpoint: null,
|
|
||||||
spectate: null,
|
|
||||||
spreadplayers: null,
|
|
||||||
stop: null,
|
|
||||||
stopsound: null,
|
|
||||||
summon: null,
|
|
||||||
tag: {
|
|
||||||
add: null,
|
|
||||||
list: null,
|
|
||||||
remove: null,
|
|
||||||
},
|
|
||||||
team: {
|
|
||||||
add: null,
|
|
||||||
empty: null,
|
|
||||||
join: null,
|
|
||||||
leave: null,
|
|
||||||
list: null,
|
|
||||||
modify: null,
|
|
||||||
remove: null,
|
|
||||||
},
|
|
||||||
teleport: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tp: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
weather: {
|
|
||||||
clear: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
rain: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
thunder: {
|
|
||||||
[DYNAMIC_ARG]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
whitelist: {
|
|
||||||
add: null,
|
|
||||||
list: null,
|
|
||||||
off: null,
|
|
||||||
on: null,
|
|
||||||
reload: null,
|
|
||||||
remove: null,
|
|
||||||
},
|
|
||||||
worldborder: {
|
|
||||||
add: null,
|
|
||||||
center: null,
|
|
||||||
damage: {
|
|
||||||
amount: null,
|
|
||||||
buffer: null,
|
|
||||||
},
|
|
||||||
get: null,
|
|
||||||
set: null,
|
|
||||||
warning: {
|
|
||||||
distance: null,
|
|
||||||
time: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xp: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullScreen = ref(false)
|
|
||||||
const commandInput = ref('')
|
|
||||||
const suggestions = ref<string[]>([])
|
|
||||||
const selectedSuggestionIndex = ref(0)
|
|
||||||
|
|
||||||
// const serverIP = computed(() => serverData.value?.net.ip ?? "");
|
|
||||||
// const serverPort = computed(() => serverData.value?.net.port ?? 0);
|
|
||||||
// const serverDomain = computed(() => serverData.value?.net.domain ?? "");
|
|
||||||
|
|
||||||
const suggestionsList = ref<HTMLUListElement | null>(null)
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: `Overview - ${serverData.value?.name ?? 'Server'} - Modrinth`,
|
title: computed(() => `Overview - ${server.value?.name ?? 'Server'} - Modrinth`),
|
||||||
})
|
})
|
||||||
|
|
||||||
const bestSuggestion = computed(() => {
|
|
||||||
if (!suggestions.value.length) return ''
|
|
||||||
const inputTokens = commandInput.value.trim().split(/\s+/)
|
|
||||||
let lastInputToken = inputTokens[inputTokens.length - 1] || ''
|
|
||||||
if (inputTokens.length - 1 === 0 && lastInputToken.startsWith('/')) {
|
|
||||||
lastInputToken = lastInputToken.slice(1)
|
|
||||||
}
|
|
||||||
const selectedSuggestion = suggestions.value[selectedSuggestionIndex.value]
|
|
||||||
const suggestionTokens = selectedSuggestion.split(/\s+/)
|
|
||||||
const lastSuggestionToken = suggestionTokens[suggestionTokens.length - 1] || ''
|
|
||||||
if (lastSuggestionToken.toLowerCase().startsWith(lastInputToken.toLowerCase())) {
|
|
||||||
return lastSuggestionToken.slice(lastInputToken.length)
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const getSuggestions = (input: string): string[] => {
|
|
||||||
const trimmedInput = input.trim()
|
|
||||||
const inputWithoutSlash = trimmedInput.startsWith('/') ? trimmedInput.slice(1) : trimmedInput
|
|
||||||
const tokens = inputWithoutSlash.split(/\s+/)
|
|
||||||
let currentLevel: any = commandTree
|
|
||||||
|
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
|
||||||
const token = tokens[i].toLowerCase()
|
|
||||||
if (currentLevel?.[token]) {
|
|
||||||
currentLevel = currentLevel[token] as any
|
|
||||||
} else if (currentLevel?.[DYNAMIC_ARG]) {
|
|
||||||
currentLevel = currentLevel[DYNAMIC_ARG] as any
|
|
||||||
} else {
|
|
||||||
if (i === tokens.length - 1) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
currentLevel = null
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentLevel) {
|
|
||||||
const lastToken = tokens[tokens.length - 1]?.toLowerCase() || ''
|
|
||||||
const possibleKeys = Object.keys(currentLevel)
|
|
||||||
if (currentLevel[DYNAMIC_ARG]) {
|
|
||||||
possibleKeys.push('<arg>')
|
|
||||||
}
|
|
||||||
return possibleKeys
|
|
||||||
.filter((key) => key === '<arg>' || key.toLowerCase().startsWith(lastToken))
|
|
||||||
.filter((k) => k !== lastToken.trim())
|
|
||||||
.map((key) => {
|
|
||||||
if (key === '<arg>') {
|
|
||||||
return [...tokens.slice(0, -1), '<arg>'].join(' ')
|
|
||||||
}
|
|
||||||
const newTokens = [...tokens.slice(0, -1), key]
|
|
||||||
return newTokens.join(' ')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendCommand = () => {
|
|
||||||
const cmd = commandInput.value.trim()
|
|
||||||
if (!props.isConnected || !cmd) return
|
|
||||||
try {
|
|
||||||
sendConsoleCommand(cmd)
|
|
||||||
commandInput.value = ''
|
|
||||||
suggestions.value = []
|
|
||||||
selectedSuggestionIndex.value = 0
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending command:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendConsoleCommand = (cmd: string) => {
|
|
||||||
try {
|
|
||||||
client.archon.sockets.send(serverId, { event: 'command', cmd })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending command:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => selectedSuggestionIndex.value,
|
|
||||||
(newVal) => {
|
|
||||||
if (suggestionsList.value) {
|
|
||||||
const selectedSuggestion = suggestionsList.value.querySelector(`#suggestion-${newVal}`)
|
|
||||||
if (selectedSuggestion) {
|
|
||||||
selectedSuggestion.scrollIntoView({ block: 'nearest' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => commandInput.value,
|
|
||||||
(newVal) => {
|
|
||||||
const trimmed = newVal.trim()
|
|
||||||
if (!trimmed) {
|
|
||||||
suggestions.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
suggestions.value = getSuggestions(newVal)
|
|
||||||
selectedSuggestionIndex.value = 0
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectNextSuggestion = () => {
|
|
||||||
if (suggestions.value.length === 0) return
|
|
||||||
selectedSuggestionIndex.value = (selectedSuggestionIndex.value + 1) % suggestions.value.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectPrevSuggestion = () => {
|
|
||||||
if (suggestions.value.length === 0) return
|
|
||||||
selectedSuggestionIndex.value =
|
|
||||||
(selectedSuggestionIndex.value - 1 + suggestions.value.length) % suggestions.value.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const acceptSuggestion = () => {
|
|
||||||
if (suggestions.value.filter((s) => s !== '<arg>').length === 0) return
|
|
||||||
const selected = suggestions.value[selectedSuggestionIndex.value]
|
|
||||||
const currentTokens = commandInput.value.trim().split(' ')
|
|
||||||
const suggestionTokens = selected.split(/\s+/).filter(Boolean)
|
|
||||||
|
|
||||||
// check if last current token is in command tree if so just add to the end
|
|
||||||
if (currentTokens[currentTokens.length - 1].toLowerCase() === suggestionTokens[0].toLowerCase()) {
|
|
||||||
/* empty */
|
|
||||||
} else {
|
|
||||||
const offset = currentTokens.length - 1 === 0 && currentTokens[0].trim().startsWith('/') ? 1 : 0
|
|
||||||
commandInput.value =
|
|
||||||
commandInput.value +
|
|
||||||
suggestionTokens[suggestionTokens.length - 1].substring(
|
|
||||||
currentTokens[currentTokens.length - 1].length - offset,
|
|
||||||
) +
|
|
||||||
' '
|
|
||||||
suggestions.value = getSuggestions(commandInput.value)
|
|
||||||
selectedSuggestionIndex.value = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectSuggestion = (index: number) => {
|
|
||||||
selectedSuggestionIndex.value = index
|
|
||||||
acceptSuggestion()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ServersManageOverviewPage />
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<ServerSidebar :route="route" :nav-links="navLinks" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
CardIcon,
|
|
||||||
InfoIcon,
|
|
||||||
ListIcon,
|
|
||||||
ModrinthIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
TextQuoteIcon,
|
|
||||||
UserIcon,
|
|
||||||
VersionIcon,
|
|
||||||
WrenchIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import { injectModrinthServerContext } from '@modrinth/ui'
|
|
||||||
import { isAdmin as isUserAdmin, type User } from '@modrinth/utils'
|
|
||||||
|
|
||||||
import ServerSidebar from '~/components/ui/servers/ServerSidebar.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const serverId = route.params.id as string
|
|
||||||
const auth = await useAuth()
|
|
||||||
|
|
||||||
const { server } = injectModrinthServerContext()
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: `Options - ${server.value?.name ?? 'Server'} - Modrinth`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const ownerId = computed(() => server.value?.owner_id ?? 'Ghost')
|
|
||||||
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value)
|
|
||||||
const isAdmin = computed(() => isUserAdmin(auth.value?.user))
|
|
||||||
|
|
||||||
const navLinks = computed(() => [
|
|
||||||
{ icon: SettingsIcon, label: 'General', href: `/hosting/manage/${serverId}/options` },
|
|
||||||
{ icon: WrenchIcon, label: 'Platform', href: `/hosting/manage/${serverId}/options/loader` },
|
|
||||||
{ icon: TextQuoteIcon, label: 'Startup', href: `/hosting/manage/${serverId}/options/startup` },
|
|
||||||
{ icon: VersionIcon, label: 'Network', href: `/hosting/manage/${serverId}/options/network` },
|
|
||||||
{
|
|
||||||
icon: ListIcon,
|
|
||||||
label: 'Properties',
|
|
||||||
href: `/hosting/manage/${serverId}/options/properties`,
|
|
||||||
shown: server.value?.status !== 'installing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: UserIcon,
|
|
||||||
label: 'Preferences',
|
|
||||||
href: `/hosting/manage/${serverId}/options/preferences`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: CardIcon,
|
|
||||||
label: 'Billing',
|
|
||||||
href: `/settings/billing#server-${serverId}`,
|
|
||||||
external: true,
|
|
||||||
shown: isOwner.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ModrinthIcon,
|
|
||||||
label: 'Admin Billing',
|
|
||||||
href: `/admin/billing/${ownerId.value}`,
|
|
||||||
external: true,
|
|
||||||
shown: isAdmin.value,
|
|
||||||
},
|
|
||||||
{ icon: InfoIcon, label: 'Info', href: `/hosting/manage/${serverId}/options/info` },
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="universal-card">
|
|
||||||
<p>You can manage your server's billing from Settings > Billing and subscriptions.</p>
|
|
||||||
<ButtonStyled>
|
|
||||||
<NuxtLink to="/settings/billing">Go to Billing</NuxtLink>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ButtonStyled } from '@modrinth/ui'
|
|
||||||
</script>
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative h-full w-full overflow-y-auto">
|
|
||||||
<div v-if="data" class="flex h-full w-full flex-col">
|
|
||||||
<div class="gap-2">
|
|
||||||
<div class="card flex flex-col gap-4">
|
|
||||||
<label for="server-name-field" class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Server name</span>
|
|
||||||
<span> This name is only visible on Modrinth.</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<StyledInput
|
|
||||||
id="server-name-field"
|
|
||||||
v-model="serverName"
|
|
||||||
wrapper-class="w-full md:w-[50%]"
|
|
||||||
:maxlength="48"
|
|
||||||
@keyup.enter="!serverName && saveGeneral"
|
|
||||||
/>
|
|
||||||
<span v-if="!serverName" class="text-sm text-rose-400">
|
|
||||||
Server name must be at least 1 character long.
|
|
||||||
</span>
|
|
||||||
<span v-if="!isValidServerName" class="text-sm text-rose-400">
|
|
||||||
Server name can contain any character.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- WIP - disable for now
|
|
||||||
<div class="card flex flex-col gap-4">
|
|
||||||
<label for="server-motd-field" class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Server MOTD</span>
|
|
||||||
<span>
|
|
||||||
The message of the day is the message that players see when they log in to the server.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<UiServersMOTDEditor :server="props.server" />
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="card flex flex-col gap-4">
|
|
||||||
<label for="server-subdomain" class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Custom URL</span>
|
|
||||||
<span> Your friends can connect to your server using this URL. </span>
|
|
||||||
</label>
|
|
||||||
<div class="flex w-full items-center gap-2 md:w-[60%]">
|
|
||||||
<StyledInput
|
|
||||||
id="server-subdomain"
|
|
||||||
v-model="serverSubdomain"
|
|
||||||
wrapper-class="h-[50%] w-[63%]"
|
|
||||||
:maxlength="32"
|
|
||||||
@keyup.enter="saveGeneral"
|
|
||||||
/>
|
|
||||||
.modrinth.gg
|
|
||||||
</div>
|
|
||||||
<div v-if="!isValidSubdomain" class="flex flex-col text-sm text-rose-400">
|
|
||||||
<span v-if="!isValidLengthSubdomain">
|
|
||||||
Subdomain must be at least 5 characters long.
|
|
||||||
</span>
|
|
||||||
<span v-if="!isValidCharsSubdomain">
|
|
||||||
Subdomain can only contain alphanumeric characters and dashes.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!data.is_medal" class="card flex flex-col gap-4">
|
|
||||||
<label for="server-icon-field" class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Server icon</span>
|
|
||||||
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div
|
|
||||||
v-tooltip="'Upload a custom Icon'"
|
|
||||||
class="group relative flex w-fit cursor-pointer items-center gap-2 rounded-xl bg-table-alternateRow"
|
|
||||||
@dragover.prevent="onDragOver"
|
|
||||||
@dragleave.prevent="onDragLeave"
|
|
||||||
@drop.prevent="onDrop"
|
|
||||||
@click="triggerFileInput"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-if="icon"
|
|
||||||
id="server-icon-field"
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
||||||
hidden
|
|
||||||
@change="uploadFile"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="absolute top-0 hidden size-24 flex-col items-center justify-center rounded-xl bg-button-bg p-2 opacity-80 group-hover:flex"
|
|
||||||
>
|
|
||||||
<EditIcon class="h-8 w-8 text-contrast" />
|
|
||||||
</div>
|
|
||||||
<ServerIcon class="size-24" :image="icon" />
|
|
||||||
</div>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button
|
|
||||||
v-tooltip="'Synchronize icon with installed modpack'"
|
|
||||||
class="my-auto"
|
|
||||||
@click="resetIcon"
|
|
||||||
>
|
|
||||||
<TransferIcon /> Sync icon
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else />
|
|
||||||
<SaveBanner
|
|
||||||
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
|
|
||||||
:server-id="serverId"
|
|
||||||
:is-updating="isUpdating || busyReasons.length > 0"
|
|
||||||
:save="saveGeneral"
|
|
||||||
:reset="resetGeneral"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { EditIcon, TransferIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
injectModrinthClient,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
injectNotificationManager,
|
|
||||||
ServerIcon,
|
|
||||||
StyledInput,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
|
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
|
||||||
|
|
||||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const { server, serverId, busyReasons } = injectModrinthServerContext()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const data = server
|
|
||||||
const serverName = ref(data.value?.name)
|
|
||||||
const serverSubdomain = ref(data.value?.net?.domain ?? '')
|
|
||||||
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5)
|
|
||||||
const isValidCharsSubdomain = computed(() => /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value))
|
|
||||||
const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidCharsSubdomain.value)
|
|
||||||
const icon = useState<string | undefined>(`server-icon-${serverId}`)
|
|
||||||
|
|
||||||
const isUpdating = ref(false)
|
|
||||||
const hasUnsavedChanges = computed(
|
|
||||||
() =>
|
|
||||||
(serverName.value && serverName.value !== data.value?.name) ||
|
|
||||||
serverSubdomain.value !== data.value?.net?.domain,
|
|
||||||
)
|
|
||||||
const isValidServerName = computed(() => (serverName.value?.length ?? 0) > 0)
|
|
||||||
|
|
||||||
watch(serverName, (oldValue) => {
|
|
||||||
if (!isValidServerName.value) {
|
|
||||||
serverName.value = oldValue
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveGeneral = async () => {
|
|
||||||
if (!isValidServerName.value || !isValidSubdomain.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
isUpdating.value = true
|
|
||||||
if (serverName.value !== data.value?.name) {
|
|
||||||
await client.archon.servers_v0.updateName(serverId, serverName.value ?? '')
|
|
||||||
}
|
|
||||||
if (serverSubdomain.value !== data.value?.net?.domain) {
|
|
||||||
try {
|
|
||||||
const result = await client.archon.servers_v0.checkSubdomainAvailability(
|
|
||||||
serverSubdomain.value,
|
|
||||||
)
|
|
||||||
const available = result.available
|
|
||||||
|
|
||||||
if (!available) {
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Subdomain not available',
|
|
||||||
text: 'The subdomain you entered is already in use.',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking subdomain availability:', error)
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Error checking availability',
|
|
||||||
text: 'Failed to verify if the subdomain is available.',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Server settings updated',
|
|
||||||
text: 'Your server settings were successfully changed.',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Failed to update server settings',
|
|
||||||
text: 'An error occurred while attempting to update your server settings.',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isUpdating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetGeneral = () => {
|
|
||||||
serverName.value = data.value?.name || ''
|
|
||||||
serverSubdomain.value = data.value?.net?.domain ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadFile = async (e: Event) => {
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
|
||||||
if (!file) {
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'No file selected',
|
|
||||||
text: 'Please select a file to upload.',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const scaledFile = await new Promise<File>((resolve, reject) => {
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
const img = new Image()
|
|
||||||
img.onload = () => {
|
|
||||||
canvas.width = 64
|
|
||||||
canvas.height = 64
|
|
||||||
ctx?.drawImage(img, 0, 0, 64, 64)
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (blob) {
|
|
||||||
resolve(new File([blob], 'server-icon.png', { type: 'image/png' }))
|
|
||||||
} else {
|
|
||||||
reject(new Error('Canvas toBlob failed'))
|
|
||||||
}
|
|
||||||
}, 'image/png')
|
|
||||||
URL.revokeObjectURL(img.src)
|
|
||||||
}
|
|
||||||
img.onerror = reject
|
|
||||||
img.src = URL.createObjectURL(file)
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (icon.value) {
|
|
||||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
|
|
||||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.kyros.files_v0.uploadFile('/server-icon.png', scaledFile).promise
|
|
||||||
await client.kyros.files_v0.uploadFile('/server-icon-original.png', file).promise
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
const img = new Image()
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
img.onload = () => {
|
|
||||||
canvas.width = 512
|
|
||||||
canvas.height = 512
|
|
||||||
ctx?.drawImage(img, 0, 0, 512, 512)
|
|
||||||
const dataURL = canvas.toDataURL('image/png')
|
|
||||||
useState(`server-icon-${serverId}`).value = dataURL
|
|
||||||
resolve()
|
|
||||||
URL.revokeObjectURL(img.src)
|
|
||||||
}
|
|
||||||
img.src = URL.createObjectURL(file)
|
|
||||||
})
|
|
||||||
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Server icon updated',
|
|
||||||
text: 'Your server icon was successfully changed.',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading icon:', error)
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Upload failed',
|
|
||||||
text: 'Failed to upload server icon.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetIcon = async () => {
|
|
||||||
if (icon.value) {
|
|
||||||
try {
|
|
||||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
|
|
||||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
|
|
||||||
|
|
||||||
useState(`server-icon-${serverId}`).value = undefined
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
|
||||||
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Server icon reset',
|
|
||||||
text: 'Your server icon was successfully reset.',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting icon:', error)
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Reset failed',
|
|
||||||
text: 'Failed to reset server icon.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragOver = (e: DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragLeave = (e: DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDrop = (e: DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
uploadFile(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerFileInput = () => {
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.type = 'file'
|
|
||||||
input.id = 'server-icon-field'
|
|
||||||
input.accept = 'image/png,image/jpeg,image/gif,image/webp'
|
|
||||||
input.onchange = uploadFile
|
|
||||||
input.click()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full w-full gap-2 overflow-y-auto">
|
|
||||||
<div class="card">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col justify-between gap-4 sm:flex-row">
|
|
||||||
<label class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">SFTP</span>
|
|
||||||
<span> SFTP allows you to access your server's files from outside of Modrinth. </span>
|
|
||||||
</label>
|
|
||||||
<ButtonStyled>
|
|
||||||
<a
|
|
||||||
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
|
|
||||||
class="!w-full sm:!w-auto"
|
|
||||||
:href="sftpUrl"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<ExternalIcon class="h-5 w-5" />
|
|
||||||
Launch SFTP
|
|
||||||
</a>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex w-full flex-row justify-between gap-2 rounded-xl bg-table-alternateRow p-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<span class="cursor-pointer font-bold text-contrast">
|
|
||||||
{{ data?.sftp_host }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="text-xs text-secondary">Server Address</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button
|
|
||||||
v-tooltip="'Copy SFTP server address'"
|
|
||||||
@click="copyToClipboard('Server address', data?.sftp_host)"
|
|
||||||
>
|
|
||||||
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
|
|
||||||
<div
|
|
||||||
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
|
|
||||||
>
|
|
||||||
<div class="flex h-8 items-center justify-between">
|
|
||||||
<span class="font-bold text-contrast">
|
|
||||||
{{ data?.sftp_username }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button
|
|
||||||
v-tooltip="'Copy SFTP username'"
|
|
||||||
@click="copyToClipboard('Username', data?.sftp_username)"
|
|
||||||
>
|
|
||||||
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-secondary">Username</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
|
|
||||||
>
|
|
||||||
<div class="flex h-8 items-center justify-between">
|
|
||||||
<span class="font-bold text-contrast">
|
|
||||||
{{
|
|
||||||
showPassword ? data?.sftp_password : '*'.repeat(data?.sftp_password?.length ?? 0)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="flex flex-row items-center gap-1">
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button
|
|
||||||
v-tooltip="'Copy SFTP password'"
|
|
||||||
@click="copyToClipboard('Password', data?.sftp_password)"
|
|
||||||
>
|
|
||||||
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button
|
|
||||||
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
|
|
||||||
@click="togglePassword"
|
|
||||||
>
|
|
||||||
<EyeIcon v-if="showPassword" class="h-5 w-5 hover:cursor-pointer" />
|
|
||||||
<EyeOffIcon v-else class="h-5 w-5 hover:cursor-pointer" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-secondary">Password</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2 class="text-xl font-bold">Info</h2>
|
|
||||||
<div class="rounded-xl bg-table-alternateRow p-4">
|
|
||||||
<table
|
|
||||||
class="min-w-full border-collapse overflow-hidden rounded-lg border-2 border-gray-300"
|
|
||||||
>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="property in properties" :key="property.name">
|
|
||||||
<td v-if="property.value !== 'Unknown'" class="py-3">
|
|
||||||
{{ property.name }}
|
|
||||||
</td>
|
|
||||||
<td v-if="property.value !== 'Unknown'" class="px-4">
|
|
||||||
<CopyCode :text="property.value" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
ButtonStyled,
|
|
||||||
CopyCode,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
injectNotificationManager,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const { server: data, serverId } = injectModrinthServerContext()
|
|
||||||
const showPassword = ref(false)
|
|
||||||
|
|
||||||
const sftpUrl = computed(() => `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`)
|
|
||||||
|
|
||||||
const togglePassword = () => {
|
|
||||||
showPassword.value = !showPassword.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToClipboard = (name: string, textToCopy?: string) => {
|
|
||||||
navigator.clipboard.writeText(textToCopy || '')
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: `${name} copied to clipboard!`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const properties = [
|
|
||||||
{ name: 'Server ID', value: serverId ?? 'Unknown' },
|
|
||||||
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
|
|
||||||
{ name: 'Kind', value: data.value?.upstream?.kind ?? data.value?.loader ?? 'Unknown' },
|
|
||||||
{ name: 'Project ID', value: data.value?.upstream?.project_id ?? 'Unknown' },
|
|
||||||
{ name: 'Version ID', value: data.value?.upstream?.version_id ?? 'Unknown' },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="contents">
|
|
||||||
<NewModal ref="newAllocationModal" header="New allocation">
|
|
||||||
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="addNewAllocation">
|
|
||||||
<label for="new-allocation-name" class="font-semibold text-contrast"> Name </label>
|
|
||||||
<StyledInput
|
|
||||||
id="new-allocation-name"
|
|
||||||
ref="newAllocationInput"
|
|
||||||
v-model="newAllocationName"
|
|
||||||
wrapper-class="w-full"
|
|
||||||
:maxlength="32"
|
|
||||||
placeholder="e.g. Secondary allocation"
|
|
||||||
/>
|
|
||||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button :disabled="!newAllocationName" type="submit">
|
|
||||||
<PlusIcon /> Create allocation
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button @click="newAllocationModal?.hide()">Cancel</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</NewModal>
|
|
||||||
|
|
||||||
<NewModal ref="editAllocationModal" header="Edit allocation">
|
|
||||||
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
|
|
||||||
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
|
|
||||||
<StyledInput
|
|
||||||
id="edit-allocation-name"
|
|
||||||
ref="editAllocationInput"
|
|
||||||
v-model="newAllocationName"
|
|
||||||
wrapper-class="w-full"
|
|
||||||
:maxlength="32"
|
|
||||||
placeholder="e.g. Secondary allocation"
|
|
||||||
/>
|
|
||||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button :disabled="!newAllocationName" type="submit">
|
|
||||||
<SaveIcon /> Update allocation
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button @click="editAllocationModal?.hide()">Cancel</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</NewModal>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
ref="confirmDeleteModal"
|
|
||||||
title="Deleting allocation"
|
|
||||||
:description="`You are deleting the allocation ${allocationToDelete}. This cannot be reserved again. Are you sure you want to proceed?`"
|
|
||||||
proceed-label="Delete"
|
|
||||||
@proceed="confirmDeleteAllocation"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="relative h-full w-full overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-if="allocationsError"
|
|
||||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center text-center">
|
|
||||||
<div class="flex flex-col items-center gap-4">
|
|
||||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
|
||||||
<IssuesIcon class="size-12 text-orange" />
|
|
||||||
</div>
|
|
||||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
|
|
||||||
</div>
|
|
||||||
<p class="text-lg text-secondary">
|
|
||||||
We couldn't load your server's network settings. Here's what we know:
|
|
||||||
<span class="break-all font-mono">{{
|
|
||||||
allocationsError?.message ?? 'Unknown error'
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
<ButtonStyled size="large" color="brand" @click="() => refetchAllocations()">
|
|
||||||
<button class="mt-6 !w-full">Retry</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
|
|
||||||
<div class="flex h-full flex-col">
|
|
||||||
<!-- Subdomain section -->
|
|
||||||
<div class="card flex flex-col gap-4">
|
|
||||||
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
|
|
||||||
<label for="user-domain" class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Generated DNS records</span>
|
|
||||||
<span>
|
|
||||||
Set up your personal domain to connect to your server via custom DNS records.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<ButtonStyled>
|
|
||||||
<button
|
|
||||||
class="!w-full sm:!w-auto"
|
|
||||||
:disabled="userDomain == ''"
|
|
||||||
@click="exportDnsRecords"
|
|
||||||
>
|
|
||||||
<UploadIcon />
|
|
||||||
<span>Export DNS records</span>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StyledInput
|
|
||||||
id="user-domain"
|
|
||||||
v-model="userDomain"
|
|
||||||
wrapper-class="w-full md:w-[50%]"
|
|
||||||
:maxlength="64"
|
|
||||||
:placeholder="exampleDomain"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow px-4 py-2"
|
|
||||||
>
|
|
||||||
<table
|
|
||||||
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
|
|
||||||
>
|
|
||||||
<tbody class="w-full">
|
|
||||||
<tr v-for="record in dnsRecords" :key="record.content" class="w-full">
|
|
||||||
<td class="w-1/6 py-3 pr-4 md:w-1/5 md:pr-8 lg:w-1/4 lg:pr-12">
|
|
||||||
<div class="flex flex-col gap-1" @click="copyText(record.type)">
|
|
||||||
<span
|
|
||||||
class="text-md font-bold tracking-wide text-contrast hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
{{ record.type }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-secondary">Type</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="w-2/6 py-3 md:w-1/3">
|
|
||||||
<div class="flex flex-col gap-1" @click="copyText(record.name)">
|
|
||||||
<span
|
|
||||||
class="text-md truncate font-bold tracking-wide text-contrast hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
{{ record.name }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-secondary">Name</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
|
|
||||||
<div class="flex flex-col gap-1" @click="copyText(record.content)">
|
|
||||||
<span
|
|
||||||
class="text-md w-fit truncate font-bold tracking-wide text-contrast hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
{{ record.content }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-secondary">Content</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<InfoIcon class="hidden sm:block" />
|
|
||||||
<span class="text-sm text-secondary">
|
|
||||||
You must own your own domain to use this feature.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Allocations section -->
|
|
||||||
<div class="card flex flex-col gap-4">
|
|
||||||
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Allocations</span>
|
|
||||||
<span>
|
|
||||||
Configure additional ports for internet-facing features like map viewers or voice
|
|
||||||
chat mods.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ButtonStyled type="standard" @click="showNewAllocationModal">
|
|
||||||
<button class="!w-full sm:!w-auto">
|
|
||||||
<PlusIcon />
|
|
||||||
<span>New allocation</span>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col overflow-hidden rounded-xl bg-table-alternateRow p-4">
|
|
||||||
<!-- Primary allocation -->
|
|
||||||
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
|
|
||||||
<span class="text-md font-bold tracking-wide text-contrast">
|
|
||||||
Primary allocation
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<CopyCode :text="`${serverIP}:${serverPrimaryPort}`" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="allocations?.[0]"
|
|
||||||
class="flex w-full flex-col gap-4 overflow-hidden rounded-xl bg-table-alternateRow p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="allocation in allocations"
|
|
||||||
:key="allocation.port"
|
|
||||||
class="border-border flex flex-col justify-between gap-4 sm:flex-row sm:items-center"
|
|
||||||
>
|
|
||||||
<div class="flex flex-row items-center gap-4">
|
|
||||||
<VersionIcon class="h-7 w-7 flex-none rotate-90" />
|
|
||||||
<div class="flex w-[20rem] flex-col justify-between sm:flex-row sm:items-center">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="text-md font-bold tracking-wide text-contrast">
|
|
||||||
{{ allocation.name }}
|
|
||||||
</span>
|
|
||||||
<span class="hidden text-xs text-secondary sm:block">Name</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span
|
|
||||||
class="text-md w-10 tracking-wide text-secondary sm:font-bold sm:text-contrast"
|
|
||||||
>
|
|
||||||
{{ allocation.port }}
|
|
||||||
</span>
|
|
||||||
<span class="hidden text-xs text-secondary sm:block">Port</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-row items-center gap-2 sm:w-auto">
|
|
||||||
<CopyCode :text="`${serverIP}:${allocation.port}`" />
|
|
||||||
<ButtonStyled icon-only>
|
|
||||||
<button
|
|
||||||
class="!w-full sm:!w-auto"
|
|
||||||
@click="showEditAllocationModal(allocation.port)"
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled icon-only color="red">
|
|
||||||
<button
|
|
||||||
class="!w-full sm:!w-auto"
|
|
||||||
@click="showConfirmDeleteModal(allocation.port)"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SaveBanner
|
|
||||||
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
|
|
||||||
:server-id="serverId"
|
|
||||||
:is-updating="isUpdating"
|
|
||||||
:save="saveNetwork"
|
|
||||||
:reset="resetNetwork"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
EditIcon,
|
|
||||||
InfoIcon,
|
|
||||||
IssuesIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SaveIcon,
|
|
||||||
TrashIcon,
|
|
||||||
UploadIcon,
|
|
||||||
VersionIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
ButtonStyled,
|
|
||||||
ConfirmModal,
|
|
||||||
CopyCode,
|
|
||||||
injectModrinthClient,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
injectNotificationManager,
|
|
||||||
NewModal,
|
|
||||||
StyledInput,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
|
||||||
import { computed, nextTick, ref } from 'vue'
|
|
||||||
|
|
||||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const { server, serverId } = injectModrinthServerContext()
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const isUpdating = ref(false)
|
|
||||||
const data = server
|
|
||||||
|
|
||||||
const serverIP = ref(data?.value?.net?.ip ?? '')
|
|
||||||
const serverSubdomain = ref(data?.value?.net?.domain ?? '')
|
|
||||||
const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
|
|
||||||
const userDomain = ref('')
|
|
||||||
const exampleDomain = 'play.example.com'
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: allocationsData,
|
|
||||||
error: allocationsError,
|
|
||||||
refetch: refetchAllocations,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ['servers', 'allocations', serverId] as const,
|
|
||||||
queryFn: () => client.archon.servers_v0.getAllocations(serverId),
|
|
||||||
})
|
|
||||||
const allocations = allocationsData
|
|
||||||
|
|
||||||
const newAllocationModal = ref<typeof NewModal>()
|
|
||||||
const editAllocationModal = ref<typeof NewModal>()
|
|
||||||
const confirmDeleteModal = ref<typeof ConfirmModal>()
|
|
||||||
const newAllocationInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const editAllocationInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const newAllocationName = ref('')
|
|
||||||
const newAllocationPort = ref(0)
|
|
||||||
const allocationToDelete = ref<number | null>(null)
|
|
||||||
|
|
||||||
const hasUnsavedChanges = computed(() => serverSubdomain.value !== data?.value?.net?.domain)
|
|
||||||
|
|
||||||
const isValidSubdomain = computed(() => /^[a-zA-Z0-9-]{5,}$/.test(serverSubdomain.value))
|
|
||||||
|
|
||||||
const addNewAllocation = async () => {
|
|
||||||
if (!newAllocationName.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.archon.servers_v0.reserveAllocation(serverId, newAllocationName.value)
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
|
||||||
|
|
||||||
newAllocationModal.value?.hide()
|
|
||||||
newAllocationName.value = ''
|
|
||||||
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Allocation reserved',
|
|
||||||
text: 'Your allocation has been reserved.',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to reserve new allocation:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showNewAllocationModal = () => {
|
|
||||||
newAllocationName.value = ''
|
|
||||||
newAllocationModal.value?.show()
|
|
||||||
nextTick(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
newAllocationInput.value?.focus()
|
|
||||||
}, 100)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showEditAllocationModal = (port: number) => {
|
|
||||||
newAllocationPort.value = port
|
|
||||||
editAllocationModal.value?.show()
|
|
||||||
nextTick(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
editAllocationInput.value?.focus()
|
|
||||||
}, 100)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showConfirmDeleteModal = (port: number) => {
|
|
||||||
allocationToDelete.value = port
|
|
||||||
confirmDeleteModal.value?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDeleteAllocation = async () => {
|
|
||||||
if (allocationToDelete.value === null) return
|
|
||||||
|
|
||||||
await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
|
||||||
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Allocation removed',
|
|
||||||
text: 'Your allocation has been removed.',
|
|
||||||
})
|
|
||||||
|
|
||||||
allocationToDelete.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const editAllocation = async () => {
|
|
||||||
if (!newAllocationName.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.archon.servers_v0.updateAllocation(
|
|
||||||
serverId,
|
|
||||||
newAllocationPort.value,
|
|
||||||
newAllocationName.value,
|
|
||||||
)
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
|
||||||
|
|
||||||
editAllocationModal.value?.hide()
|
|
||||||
newAllocationName.value = ''
|
|
||||||
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Allocation updated',
|
|
||||||
text: 'Your allocation has been updated.',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to reserve new allocation:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveNetwork = async () => {
|
|
||||||
if (!isValidSubdomain.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
isUpdating.value = true
|
|
||||||
const result = await client.archon.servers_v0.checkSubdomainAvailability(serverSubdomain.value)
|
|
||||||
const available = result.available
|
|
||||||
if (!available) {
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Subdomain not available',
|
|
||||||
text: 'The subdomain you entered is already in use.',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (serverSubdomain.value !== data?.value?.net?.domain) {
|
|
||||||
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
|
|
||||||
}
|
|
||||||
if (serverPrimaryPort.value !== data?.value?.net?.port) {
|
|
||||||
await client.archon.servers_v0.updateAllocation(
|
|
||||||
serverId,
|
|
||||||
serverPrimaryPort.value,
|
|
||||||
newAllocationName.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Server settings updated',
|
|
||||||
text: 'Your server settings were successfully changed.',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Failed to update server settings',
|
|
||||||
text: 'An error occurred while attempting to update your server settings.',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isUpdating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetNetwork = () => {
|
|
||||||
serverSubdomain.value = data?.value?.net?.domain ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const dnsRecords = computed(() => {
|
|
||||||
const domain = userDomain.value === '' ? exampleDomain : userDomain.value
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'A',
|
|
||||||
name: `${domain}`,
|
|
||||||
content: data.value?.net?.ip ?? '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'SRV',
|
|
||||||
name: `_minecraft._tcp.${domain}`,
|
|
||||||
content: `0 10 ${data.value?.net?.port} ${domain}`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const exportDnsRecords = () => {
|
|
||||||
const records = dnsRecords.value.reduce(
|
|
||||||
(acc, record) => {
|
|
||||||
const type = record.type
|
|
||||||
if (!acc[type]) {
|
|
||||||
acc[type] = []
|
|
||||||
}
|
|
||||||
acc[type].push(record)
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{} as Record<string, any[]>,
|
|
||||||
)
|
|
||||||
|
|
||||||
const text = Object.entries(records)
|
|
||||||
.map(([type, records]) => {
|
|
||||||
return `; ${type} Records\n${records.map((record) => `${record.name}. 1 IN ${record.type} ${record.content}${record.type === 'SRV' ? '.' : ''}`).join('\n')}\n`
|
|
||||||
})
|
|
||||||
.join('\n')
|
|
||||||
const blob = new Blob([text], { type: 'text/plain' })
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = window.URL.createObjectURL(blob)
|
|
||||||
a.download = `${userDomain.value}.txt`
|
|
||||||
a.click()
|
|
||||||
a.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyText = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Text copied',
|
|
||||||
text: `${text} has been copied to your clipboard`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full w-full">
|
|
||||||
<div class="h-full w-full gap-2 overflow-y-auto">
|
|
||||||
<div class="card flex flex-col gap-4">
|
|
||||||
<h1 class="m-0 text-lg font-bold text-contrast">Server preferences</h1>
|
|
||||||
<p class="m-0">Preferences apply per server and changes are only saved in your browser.</p>
|
|
||||||
<div
|
|
||||||
v-for="(prefConfig, key) in preferences"
|
|
||||||
:key="key"
|
|
||||||
class="flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<label :for="`pref-${key}`" class="flex flex-col gap-2">
|
|
||||||
<div class="flex flex-row gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">{{ prefConfig.displayName }}</span>
|
|
||||||
<div
|
|
||||||
v-if="prefConfig.implemented === false"
|
|
||||||
class="hidden items-center gap-1 rounded-full bg-table-alternateRow p-1 px-1.5 text-xs font-semibold sm:flex"
|
|
||||||
>
|
|
||||||
Coming Soon
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span>{{ prefConfig.description }}</span>
|
|
||||||
</label>
|
|
||||||
<Toggle
|
|
||||||
:id="`pref-${key}`"
|
|
||||||
v-model="newUserPreferences[key]"
|
|
||||||
class="flex-none"
|
|
||||||
:disabled="prefConfig.implemented === false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SaveBanner
|
|
||||||
:is-visible="hasUnsavedChanges"
|
|
||||||
:server-id="serverId"
|
|
||||||
:is-updating="false"
|
|
||||||
:save="savePreferences"
|
|
||||||
:reset="resetPreferences"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { injectNotificationManager, Toggle } from '@modrinth/ui'
|
|
||||||
import { useStorage } from '@vueuse/core'
|
|
||||||
|
|
||||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const route = useNativeRoute()
|
|
||||||
const serverId = route.params.id as string
|
|
||||||
|
|
||||||
const preferences = {
|
|
||||||
ramAsNumber: {
|
|
||||||
displayName: 'RAM as bytes',
|
|
||||||
description:
|
|
||||||
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
|
|
||||||
implemented: true,
|
|
||||||
},
|
|
||||||
hideSubdomainLabel: {
|
|
||||||
displayName: 'Hide subdomain label',
|
|
||||||
description: 'When enabled, the subdomain label will be hidden from the server header.',
|
|
||||||
implemented: true,
|
|
||||||
},
|
|
||||||
autoRestart: {
|
|
||||||
displayName: 'Auto restart',
|
|
||||||
description: 'When enabled, your server will automatically restart if it crashes.',
|
|
||||||
implemented: false,
|
|
||||||
},
|
|
||||||
powerDontAskAgain: {
|
|
||||||
displayName: 'Power actions confirmation',
|
|
||||||
description: 'When enabled, you will be prompted before stopping and restarting your server.',
|
|
||||||
implemented: true,
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
type PreferenceKeys = keyof typeof preferences
|
|
||||||
|
|
||||||
type UserPreferences = {
|
|
||||||
[K in PreferenceKeys]: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
|
||||||
ramAsNumber: false,
|
|
||||||
hideSubdomainLabel: false,
|
|
||||||
autoRestart: false,
|
|
||||||
powerDontAskAgain: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPreferences = useStorage<UserPreferences>(
|
|
||||||
`pyro-server-${serverId}-preferences`,
|
|
||||||
defaultPreferences,
|
|
||||||
)
|
|
||||||
|
|
||||||
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)))
|
|
||||||
|
|
||||||
const hasUnsavedChanges = computed(() => {
|
|
||||||
return JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const savePreferences = () => {
|
|
||||||
userPreferences.value = { ...newUserPreferences.value }
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Preferences saved',
|
|
||||||
text: 'Your preferences have been saved.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetPreferences = () => {
|
|
||||||
newUserPreferences.value = { ...userPreferences.value }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative h-full w-full select-none overflow-y-auto">
|
|
||||||
<div v-if="propsData" class="flex h-full w-full flex-col justify-between gap-4 overflow-y-auto">
|
|
||||||
<Admonition
|
|
||||||
v-if="hasNoProperties"
|
|
||||||
type="warning"
|
|
||||||
body="Some expected properties are missing from your server.properties - this usually means the server hasn't completed its first startup yet."
|
|
||||||
/>
|
|
||||||
<div class="card flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h2 class="m-0 text-lg font-bold text-contrast">Server properties</h2>
|
|
||||||
<div class="m-0">
|
|
||||||
Edit the Minecraft server properties file. If you're unsure about a specific property,
|
|
||||||
the
|
|
||||||
<NuxtLink
|
|
||||||
class="goto-link !inline-block"
|
|
||||||
to="https://minecraft.wiki/w/Server.properties"
|
|
||||||
external
|
|
||||||
>
|
|
||||||
Minecraft Wiki
|
|
||||||
</NuxtLink>
|
|
||||||
has more detailed information.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
|
|
||||||
<div class="w-full text-sm">
|
|
||||||
<label for="search-server-properties" class="sr-only"> Search server properties </label>
|
|
||||||
<StyledInput
|
|
||||||
id="search-server-properties"
|
|
||||||
v-model="searchInput"
|
|
||||||
wrapper-class="w-full"
|
|
||||||
type="search"
|
|
||||||
:icon="SearchIcon"
|
|
||||||
name="search"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Search server properties..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(_value, key) in filteredProperties"
|
|
||||||
:key="key"
|
|
||||||
class="flex flex-row flex-wrap items-center justify-between py-2"
|
|
||||||
>
|
|
||||||
<span :id="`property-label-${key}`">{{ formatPropertyName(key) }}</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="getPropertyDef(key).type === 'dropdown'"
|
|
||||||
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
|
|
||||||
>
|
|
||||||
<Combobox
|
|
||||||
:id="`server-property-${key}`"
|
|
||||||
v-model="liveProperties[key]"
|
|
||||||
:name="formatPropertyName(key)"
|
|
||||||
:options="
|
|
||||||
(getPropertyDef(key) as DropdownPropertyDef).options.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: formatPropertyName(v),
|
|
||||||
}))
|
|
||||||
"
|
|
||||||
:aria-labelledby="`property-label-${key}`"
|
|
||||||
:display-value="formatPropertyName(String(liveProperties[key] ?? 'Select...'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="getPropertyDef(key).type === 'toggle'" class="flex justify-end">
|
|
||||||
<Toggle
|
|
||||||
:id="`server-property-${key}`"
|
|
||||||
:model-value="liveProperties[key] === 'true'"
|
|
||||||
:aria-labelledby="`property-label-${key}`"
|
|
||||||
@update:model-value="liveProperties[key] = $event ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="getPropertyDef(key).type === 'number'" class="mt-2 w-full sm:w-[320px]">
|
|
||||||
<StyledInput
|
|
||||||
:id="`server-property-${key}`"
|
|
||||||
:model-value="liveProperties[key]"
|
|
||||||
type="number"
|
|
||||||
wrapper-class="w-full"
|
|
||||||
:aria-labelledby="`property-label-${key}`"
|
|
||||||
@update:model-value="liveProperties[key] = String($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
|
|
||||||
<StyledInput
|
|
||||||
:id="`server-property-${key}`"
|
|
||||||
v-model="liveProperties[key]"
|
|
||||||
wrapper-class="w-full"
|
|
||||||
:aria-labelledby="`property-label-${key}`"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex h-full w-full items-center justify-center">
|
|
||||||
<SpinnerIcon class="animate-spin" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SaveBanner
|
|
||||||
:is-visible="hasUnsavedChanges"
|
|
||||||
:server-id="serverId"
|
|
||||||
:is-updating="isUpdating || busyReasons.length > 0"
|
|
||||||
restart
|
|
||||||
:save="() => saveProperties()"
|
|
||||||
:reset="resetProperties"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Archon } from '@modrinth/api-client'
|
|
||||||
import { SearchIcon, SpinnerIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
Admonition,
|
|
||||||
Combobox,
|
|
||||||
injectModrinthClient,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
injectNotificationManager,
|
|
||||||
StyledInput,
|
|
||||||
Toggle,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
|
||||||
import Fuse from 'fuse.js'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const searchInput = ref('')
|
|
||||||
|
|
||||||
type DropdownPropertyDef = { type: 'dropdown'; options: string[] }
|
|
||||||
type PropertyDef = { type: 'toggle' } | { type: 'number' } | { type: 'text' } | DropdownPropertyDef
|
|
||||||
|
|
||||||
const KNOWN_PROPERTIES: Record<string, PropertyDef> = {
|
|
||||||
allow_cheats: { type: 'toggle' },
|
|
||||||
allow_flight: { type: 'toggle' },
|
|
||||||
difficulty: { type: 'dropdown', options: ['peaceful', 'easy', 'normal', 'hard'] },
|
|
||||||
enforce_whitelist: { type: 'toggle' },
|
|
||||||
force_gamemode: { type: 'toggle' },
|
|
||||||
gamemode: { type: 'dropdown', options: ['survival', 'creative', 'adventure', 'spectator'] },
|
|
||||||
generate_structures: { type: 'toggle' },
|
|
||||||
generator_settings: { type: 'text' },
|
|
||||||
hardcore: { type: 'toggle' },
|
|
||||||
level_seed: { type: 'text' },
|
|
||||||
level_type: { type: 'text' },
|
|
||||||
max_players: { type: 'number' },
|
|
||||||
max_tick_time: { type: 'number' },
|
|
||||||
motd: { type: 'text' },
|
|
||||||
pause_when_empty_seconds: { type: 'number' },
|
|
||||||
player_idle_timeout: { type: 'number' },
|
|
||||||
require_resource_pack: { type: 'toggle' },
|
|
||||||
resource_pack: { type: 'text' },
|
|
||||||
resource_pack_id: { type: 'text' },
|
|
||||||
resource_pack_sha1: { type: 'text' },
|
|
||||||
simulation_distance: { type: 'number' },
|
|
||||||
spawn_protection: { type: 'number' },
|
|
||||||
sync_chunk_writes: { type: 'toggle' },
|
|
||||||
view_distance: { type: 'number' },
|
|
||||||
white_list: { type: 'toggle' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPropertyDef(key: string): PropertyDef {
|
|
||||||
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryKey = computed(() => ['servers', 'properties', 'v1', serverId, worldId.value])
|
|
||||||
|
|
||||||
const { data: propsData } = useQuery({
|
|
||||||
queryKey,
|
|
||||||
queryFn: () => client.archon.properties_v1.getProperties(serverId, worldId.value!),
|
|
||||||
enabled: computed(() => worldId.value !== null),
|
|
||||||
})
|
|
||||||
|
|
||||||
function flattenProperties(data: Archon.Content.v1.PropertiesFields): Record<string, string> {
|
|
||||||
const result: Record<string, string> = {}
|
|
||||||
if (data.known) {
|
|
||||||
for (const [key, value] of Object.entries(data.known)) {
|
|
||||||
if (value != null) result[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.custom) {
|
|
||||||
for (const [key, value] of Object.entries(data.custom)) {
|
|
||||||
if (value != null) result[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const liveProperties = ref<Record<string, string>>({})
|
|
||||||
const originalProperties = ref<Record<string, string>>({})
|
|
||||||
|
|
||||||
function syncFormFromData() {
|
|
||||||
if (!propsData.value) return
|
|
||||||
const flat = flattenProperties(propsData.value)
|
|
||||||
liveProperties.value = { ...flat }
|
|
||||||
originalProperties.value = { ...flat }
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasNoProperties = computed(() => Object.keys(liveProperties.value).length === 0)
|
|
||||||
|
|
||||||
const hasUnsavedChanges = computed(() =>
|
|
||||||
Object.keys(liveProperties.value).some(
|
|
||||||
(key) => liveProperties.value[key] !== originalProperties.value[key],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
propsData,
|
|
||||||
(newData) => {
|
|
||||||
if (newData && !hasUnsavedChanges.value) {
|
|
||||||
syncFormFromData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(powerState, () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKey.value })
|
|
||||||
})
|
|
||||||
|
|
||||||
function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
|
|
||||||
const known: Record<string, string> = {}
|
|
||||||
const custom: Record<string, string> = {}
|
|
||||||
|
|
||||||
for (const key of Object.keys(liveProperties.value)) {
|
|
||||||
if (liveProperties.value[key] === originalProperties.value[key]) continue
|
|
||||||
if (key in KNOWN_PROPERTIES) {
|
|
||||||
known[key] = liveProperties.value[key]
|
|
||||||
} else {
|
|
||||||
custom[key] = liveProperties.value[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const patch: Archon.Content.v1.PatchPropertiesFields = {}
|
|
||||||
if (Object.keys(known).length > 0) {
|
|
||||||
patch.known = known as Archon.Content.v1.KnownPropertiesFields
|
|
||||||
}
|
|
||||||
if (Object.keys(custom).length > 0) {
|
|
||||||
patch.custom = custom
|
|
||||||
}
|
|
||||||
return patch
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mutate: saveProperties, isPending: isUpdating } = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
|
|
||||||
onSuccess: async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
|
||||||
syncFormFromData()
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Server properties updated',
|
|
||||||
text: 'Your server properties were successfully changed.',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Failed to update server properties',
|
|
||||||
text: error instanceof Error ? error.message : 'An error occurred.',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function resetProperties() {
|
|
||||||
syncFormFromData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const fuse = computed(() => {
|
|
||||||
const entries = Object.entries(liveProperties.value).map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
value: String(value),
|
|
||||||
}))
|
|
||||||
return new Fuse(entries, { keys: ['key', 'value'], threshold: 0.2 })
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredProperties = computed(() => {
|
|
||||||
if (!searchInput.value?.trim()) return liveProperties.value
|
|
||||||
const results = fuse.value.search(searchInput.value)
|
|
||||||
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatPropertyName(name: string): string {
|
|
||||||
return name
|
|
||||||
.split('_')
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' ')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative h-full w-full">
|
|
||||||
<div class="flex h-full w-full flex-col gap-4">
|
|
||||||
<div
|
|
||||||
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
|
|
||||||
>
|
|
||||||
These settings are for advanced users. Changing them can break your server.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gap-2">
|
|
||||||
<div class="card flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col justify-between gap-4 sm:flex-row">
|
|
||||||
<label for="startup-command-field" class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Startup command</span>
|
|
||||||
<span> The command that runs when your server is started. </span>
|
|
||||||
</label>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button
|
|
||||||
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
|
|
||||||
class="!w-full sm:!w-auto"
|
|
||||||
@click="resetToDefault"
|
|
||||||
>
|
|
||||||
<UpdatedIcon class="h-5 w-5" />
|
|
||||||
Restore default command
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<StyledInput
|
|
||||||
id="startup-command-field"
|
|
||||||
v-model="startupCommand"
|
|
||||||
multiline
|
|
||||||
resize="vertical"
|
|
||||||
input-class="min-h-[270px] font-[family-name:var(--mono-font)]"
|
|
||||||
:disabled="isStartupLoading"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="isStartupLoading"
|
|
||||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
|
||||||
>
|
|
||||||
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card flex flex-col gap-8">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Java version</span>
|
|
||||||
<span>
|
|
||||||
The version of Java that your server will run on. By default, only the Java versions
|
|
||||||
compatible with this version of Minecraft are shown. Some mods may require a
|
|
||||||
different Java version to work properly.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="relative max-w-xs">
|
|
||||||
<Combobox
|
|
||||||
:id="'java-version-field'"
|
|
||||||
v-model="javaVersion"
|
|
||||||
name="java-version"
|
|
||||||
:options="displayedJavaVersions"
|
|
||||||
:display-value="javaVersionLabel ?? 'Java Version'"
|
|
||||||
:disabled="isStartupLoading"
|
|
||||||
>
|
|
||||||
<template #dropdown-footer>
|
|
||||||
<button
|
|
||||||
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
|
|
||||||
@mousedown.prevent
|
|
||||||
@click="showAllVersions = !showAllVersions"
|
|
||||||
>
|
|
||||||
<EyeOffIcon v-if="showAllVersions" class="size-4" />
|
|
||||||
<EyeIcon v-else class="size-4" />
|
|
||||||
{{ showAllVersions ? 'Hide extra versions' : 'Show all versions' }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</Combobox>
|
|
||||||
<div
|
|
||||||
v-if="isStartupLoading"
|
|
||||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
|
||||||
>
|
|
||||||
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<span class="text-lg font-bold text-contrast">Runtime</span>
|
|
||||||
<span> The Java runtime your server will use. </span>
|
|
||||||
</div>
|
|
||||||
<div class="relative max-w-xs">
|
|
||||||
<Combobox
|
|
||||||
:id="'runtime-field'"
|
|
||||||
v-model="jreVendor"
|
|
||||||
name="runtime"
|
|
||||||
:options="JRE_VENDORS"
|
|
||||||
:display-value="jreVendorLabel ?? 'Runtime'"
|
|
||||||
:disabled="isStartupLoading"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="isStartupLoading"
|
|
||||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
|
||||||
>
|
|
||||||
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SaveBanner
|
|
||||||
:is-visible="!!hasUnsavedChanges"
|
|
||||||
:server-id="serverId"
|
|
||||||
:is-updating="isPending"
|
|
||||||
:save="() => saveStartup()"
|
|
||||||
:reset="resetStartup"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Archon } from '@modrinth/api-client'
|
|
||||||
import { EyeIcon, EyeOffIcon, SpinnerIcon, UpdatedIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
ButtonStyled,
|
|
||||||
Combobox,
|
|
||||||
injectModrinthClient,
|
|
||||||
injectModrinthServerContext,
|
|
||||||
injectNotificationManager,
|
|
||||||
StyledInput,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
|
||||||
|
|
||||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const { server, serverId, worldId } = injectModrinthServerContext()
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const startupQueryKey = computed(() => ['servers', 'startup', 'v1', serverId, worldId.value])
|
|
||||||
|
|
||||||
const { data: startupData, isLoading: isStartupLoading } = useQuery({
|
|
||||||
queryKey: startupQueryKey,
|
|
||||||
queryFn: () => client.archon.options_v1.getStartup(serverId, worldId.value!),
|
|
||||||
enabled: computed(() => worldId.value !== null),
|
|
||||||
})
|
|
||||||
|
|
||||||
const JAVA_VERSIONS = [
|
|
||||||
{ value: 8, label: 'Java 8' },
|
|
||||||
{ value: 11, label: 'Java 11' },
|
|
||||||
{ value: 17, label: 'Java 17' },
|
|
||||||
{ value: 21, label: 'Java 21' },
|
|
||||||
{ value: 25, label: 'Java 25' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
|
|
||||||
{ value: 'corretto', label: 'Corretto' },
|
|
||||||
{ value: 'temurin', label: 'Temurin' },
|
|
||||||
{ value: 'graal', label: 'GraalVM' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Saved state derived directly from query
|
|
||||||
const savedStartupCommand = computed(() => startupData.value?.startup_command ?? '')
|
|
||||||
const savedJavaVersion = computed(() => startupData.value?.java_version ?? undefined)
|
|
||||||
const savedJreVendor = computed(() => startupData.value?.jre_vendor ?? undefined)
|
|
||||||
const defaultStartupCommand = computed(
|
|
||||||
() => startupData.value?.original_invocation ?? savedStartupCommand.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Local form state
|
|
||||||
const startupCommand = ref('')
|
|
||||||
const javaVersion = ref<number>()
|
|
||||||
const jreVendor = ref<Archon.Content.v1.JreVendor>()
|
|
||||||
|
|
||||||
// Display labels for comboboxes
|
|
||||||
const javaVersionLabel = computed(
|
|
||||||
() => JAVA_VERSIONS.find((v) => v.value === javaVersion.value)?.label,
|
|
||||||
)
|
|
||||||
const jreVendorLabel = computed(() => JRE_VENDORS.find((v) => v.value === jreVendor.value)?.label)
|
|
||||||
|
|
||||||
function syncFormFromData() {
|
|
||||||
startupCommand.value = savedStartupCommand.value
|
|
||||||
javaVersion.value = savedJavaVersion.value
|
|
||||||
jreVendor.value = savedJreVendor.value
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
startupData,
|
|
||||||
(newData, oldData) => {
|
|
||||||
if (newData && !oldData) {
|
|
||||||
syncFormFromData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasUnsavedChanges = computed(
|
|
||||||
() =>
|
|
||||||
startupCommand.value !== savedStartupCommand.value ||
|
|
||||||
javaVersion.value !== savedJavaVersion.value ||
|
|
||||||
jreVendor.value !== savedJreVendor.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Java version filtering
|
|
||||||
const showAllVersions = ref(false)
|
|
||||||
|
|
||||||
type MinecraftReleaseVersion = {
|
|
||||||
major: number
|
|
||||||
minor: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMinecraftReleaseVersion(version: string): MinecraftReleaseVersion | null {
|
|
||||||
const [majorPart, minorPart] = version.split('.')
|
|
||||||
|
|
||||||
if (!majorPart || !minorPart) return null
|
|
||||||
|
|
||||||
const major = Number(majorPart)
|
|
||||||
const minor = Number(minorPart)
|
|
||||||
|
|
||||||
if (!Number.isInteger(major) || !Number.isInteger(minor)) return null
|
|
||||||
|
|
||||||
return { major, minor }
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterJavaVersions(compatibleVersions: number[]) {
|
|
||||||
return JAVA_VERSIONS.filter((version) => compatibleVersions.includes(version.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayedJavaVersions = computed(() => {
|
|
||||||
if (showAllVersions.value) return JAVA_VERSIONS
|
|
||||||
|
|
||||||
const mcVersion = server.value?.mc_version ?? ''
|
|
||||||
if (!mcVersion) return JAVA_VERSIONS
|
|
||||||
|
|
||||||
const releaseVersion = parseMinecraftReleaseVersion(mcVersion)
|
|
||||||
if (!releaseVersion) return JAVA_VERSIONS
|
|
||||||
|
|
||||||
if (releaseVersion.major > 1) {
|
|
||||||
if (releaseVersion.major >= 26) {
|
|
||||||
return filterJavaVersions([25])
|
|
||||||
}
|
|
||||||
|
|
||||||
return JAVA_VERSIONS
|
|
||||||
}
|
|
||||||
|
|
||||||
if (releaseVersion.minor >= 20) return filterJavaVersions([21])
|
|
||||||
if (releaseVersion.minor >= 17) return filterJavaVersions([17, 21])
|
|
||||||
if (releaseVersion.minor >= 12) return filterJavaVersions([8, 11, 17, 21])
|
|
||||||
if (releaseVersion.minor >= 6) return filterJavaVersions([8, 11])
|
|
||||||
return filterJavaVersions([8])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Save mutation
|
|
||||||
const { mutate: saveStartup, isPending } = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
|
|
||||||
startup_command: startupCommand.value || null,
|
|
||||||
java_version: javaVersion.value ?? null,
|
|
||||||
jre_vendor: jreVendor.value ?? null,
|
|
||||||
}),
|
|
||||||
onSuccess: async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: startupQueryKey.value })
|
|
||||||
syncFormFromData()
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Server settings updated',
|
|
||||||
text: 'Your server settings were successfully changed.',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error(error)
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Failed to update server arguments',
|
|
||||||
text: 'Please try again later.',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function resetStartup() {
|
|
||||||
syncFormFromData()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetToDefault() {
|
|
||||||
startupCommand.value = defaultStartupCommand.value
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -8,7 +8,7 @@ definePageMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Servers - Modrinth',
|
title: 'Hosting - Modrinth',
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -20,5 +20,6 @@ const generatedState = useGeneratedState()
|
|||||||
:stripe-publishable-key="config.public.stripePublishableKey"
|
:stripe-publishable-key="config.public.stripePublishableKey"
|
||||||
:site-url="config.public.siteUrl"
|
:site-url="config.public.siteUrl"
|
||||||
:products="generatedState.products || []"
|
:products="generatedState.products || []"
|
||||||
|
class="max-w-[1280px] py-0"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<ServersUpgradeModalWrapper ref="upgradeModal" />
|
<ServersUpgradeModalWrapper ref="upgradeModal" />
|
||||||
|
<ResubscribeModal ref="pyroResubscribeModal" @resubscribe="handlePyroResubscribeConfirm" />
|
||||||
<section class="universal-card experimental-styles-within">
|
<section class="universal-card experimental-styles-within">
|
||||||
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
|
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
|
||||||
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
|
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
|
||||||
@@ -284,6 +285,8 @@
|
|||||||
v-if="subscription.serverInfo"
|
v-if="subscription.serverInfo"
|
||||||
v-bind="subscription.serverInfo"
|
v-bind="subscription.serverInfo"
|
||||||
:pending-change="getPendingChange(subscription)"
|
:pending-change="getPendingChange(subscription)"
|
||||||
|
:cancellation-date="getCancellationDate(subscription)"
|
||||||
|
:on-download-backup="getBackupDownloadForServer(subscription.serverInfo)"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-fit">
|
<div v-else class="w-fit">
|
||||||
<p>
|
<p>
|
||||||
@@ -514,15 +517,9 @@
|
|||||||
"
|
"
|
||||||
color="green"
|
color="green"
|
||||||
>
|
>
|
||||||
<button
|
<button @click="openPyroResubscribeModal(subscription)">
|
||||||
@click="
|
{{ formatMessage(messages.resubscribe) }}
|
||||||
resubscribePyro(
|
<RightArrowIcon />
|
||||||
subscription.id,
|
|
||||||
$dayjs(getPyroCharge(subscription).due).isBefore($dayjs()),
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ formatMessage(messages.resubscribe) }} <RightArrowIcon />
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -709,21 +706,25 @@ import {
|
|||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
paymentMethodMessages,
|
paymentMethodMessages,
|
||||||
PurchaseModal,
|
PurchaseModal,
|
||||||
|
ResubscribeModal,
|
||||||
ServerListing,
|
ServerListing,
|
||||||
useFormatDateTime,
|
useFormatDateTime,
|
||||||
useFormatPrice,
|
useFormatPrice,
|
||||||
|
useServerBackupDownload,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { calculateSavings, getCurrency } from '@modrinth/utils'
|
import { calculateSavings, getCurrency } from '@modrinth/utils'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { computed, ref } from 'vue'
|
import { useIntervalFn } from '@vueuse/core'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
|
import ModrinthServersIcon from '~/components/brand/ModrinthServersIcon.vue'
|
||||||
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
|
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
|
||||||
import { products } from '~/generated/state.json'
|
import { products } from '~/generated/state.json'
|
||||||
|
|
||||||
const { addNotification, handleError } = injectNotificationManager()
|
const { addNotification, handleError } = injectNotificationManager()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
|
const { getLatestBackupDownload } = useServerBackupDownload()
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
})
|
})
|
||||||
@@ -835,6 +836,14 @@ const messages = defineMessages({
|
|||||||
id: 'settings.billing.interval.year',
|
id: 'settings.billing.interval.year',
|
||||||
defaultMessage: 'year',
|
defaultMessage: 'year',
|
||||||
},
|
},
|
||||||
|
intervalQuarter: {
|
||||||
|
id: 'settings.billing.interval.quarter',
|
||||||
|
defaultMessage: 'quarter',
|
||||||
|
},
|
||||||
|
intervalQuarterly: {
|
||||||
|
id: 'settings.billing.interval.quarterly.adjective',
|
||||||
|
defaultMessage: 'quarterly',
|
||||||
|
},
|
||||||
intervalMonthly: {
|
intervalMonthly: {
|
||||||
id: 'settings.billing.interval.monthly',
|
id: 'settings.billing.interval.monthly',
|
||||||
defaultMessage: 'monthly',
|
defaultMessage: 'monthly',
|
||||||
@@ -998,7 +1007,7 @@ const messages = defineMessages({
|
|||||||
pyroResubscribeRequestSubmittedText: {
|
pyroResubscribeRequestSubmittedText: {
|
||||||
id: 'settings.billing.pyro.resubscribe.request-submitted.text',
|
id: 'settings.billing.pyro.resubscribe.request-submitted.text',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
|
'If the server is currently cancelled, it may take 10-15 minutes to set up the server.',
|
||||||
},
|
},
|
||||||
pyroResubscribeSuccessText: {
|
pyroResubscribeSuccessText: {
|
||||||
id: 'settings.billing.pyro.resubscribe.success.text',
|
id: 'settings.billing.pyro.resubscribe.success.text',
|
||||||
@@ -1015,15 +1024,20 @@ const messages = defineMessages({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function getIntervalNounLabel(interval) {
|
function getIntervalNounLabel(interval) {
|
||||||
|
console.log(interval)
|
||||||
return interval === 'yearly'
|
return interval === 'yearly'
|
||||||
? formatMessage(messages.intervalYear)
|
? formatMessage(messages.intervalYear)
|
||||||
: formatMessage(messages.intervalMonth)
|
: interval === 'quarterly'
|
||||||
|
? formatMessage(messages.intervalQuarter)
|
||||||
|
: formatMessage(messages.intervalMonth)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIntervalAdjectiveLabel(interval) {
|
function getIntervalAdjectiveLabel(interval) {
|
||||||
return interval === 'yearly'
|
return interval === 'yearly'
|
||||||
? formatMessage(messages.intervalYearly)
|
? formatMessage(messages.intervalYearly)
|
||||||
: formatMessage(messages.intervalMonthly)
|
: interval === 'quarterly'
|
||||||
|
? formatMessage(messages.intervalQuarterly)
|
||||||
|
: formatMessage(messages.intervalMonthly)
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -1053,6 +1067,11 @@ const { data: serversData } = useQuery({
|
|||||||
queryFn: () => client.archon.servers_v0.list(),
|
queryFn: () => client.archon.servers_v0.list(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: serverFullList } = useQuery({
|
||||||
|
queryKey: ['servers', 'v1'],
|
||||||
|
queryFn: () => client.archon.servers_v1.list(),
|
||||||
|
})
|
||||||
|
|
||||||
const midasProduct = ref(products.find((x) => x.metadata?.type === 'midas'))
|
const midasProduct = ref(products.find((x) => x.metadata?.type === 'midas'))
|
||||||
const midasSubscription = computed(() =>
|
const midasSubscription = computed(() =>
|
||||||
subscriptions.value?.find(
|
subscriptions.value?.find(
|
||||||
@@ -1082,16 +1101,38 @@ const pyroSubscriptions = computed(() => {
|
|||||||
const pyroSubs = subscriptions.value?.filter((s) => s?.metadata?.type === 'pyro') || []
|
const pyroSubs = subscriptions.value?.filter((s) => s?.metadata?.type === 'pyro') || []
|
||||||
const servers = serversData.value?.servers || []
|
const servers = serversData.value?.servers || []
|
||||||
|
|
||||||
return pyroSubs.map((subscription) => {
|
return pyroSubs
|
||||||
const server = servers.find((s) => s.server_id === subscription.metadata.id)
|
.map((subscription) => {
|
||||||
return {
|
const server = servers.find((s) => s.server_id === subscription.metadata.id)
|
||||||
...subscription,
|
const charge = getPyroCharge(subscription)
|
||||||
serverInfo: server,
|
|
||||||
}
|
return {
|
||||||
})
|
...subscription,
|
||||||
|
serverInfo: {
|
||||||
|
...server,
|
||||||
|
isProvisioning:
|
||||||
|
subscription.status === 'unprovisioned' &&
|
||||||
|
(charge?.status === 'processing' || charge?.status === 'open'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((subscription) => {
|
||||||
|
// files expire 30 days after cancellation
|
||||||
|
const cancellationDate = getCancellationDate(subscription)
|
||||||
|
if (
|
||||||
|
!cancellationDate ||
|
||||||
|
subscription.serverInfo?.status !== 'suspended' ||
|
||||||
|
subscription.serverInfo?.suspension_reason !== 'cancelled'
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
const cancellation = new Date(cancellationDate)
|
||||||
|
const thirtyDaysLater = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||||
|
return new Date() <= thirtyDaysLater
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const midasPurchaseModal = ref()
|
const midasPurchaseModal = ref()
|
||||||
|
const pyroResubscribeModal = ref()
|
||||||
const country = useUserCountry()
|
const country = useUserCountry()
|
||||||
const price = computed(() =>
|
const price = computed(() =>
|
||||||
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
|
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
|
||||||
@@ -1201,13 +1242,20 @@ const getProductFromPriceId = (priceId) => {
|
|||||||
return productsData.value.find((p) => p.prices?.some((x) => x.id === priceId))
|
return productsData.value.find((p) => p.prices?.some((x) => x.id === priceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPyroCharge = (subscription) => {
|
function getPyroCharge(subscription) {
|
||||||
if (!subscription || !charges.value) return null
|
if (!subscription || !charges.value) return null
|
||||||
return charges.value.find(
|
return charges.value.find(
|
||||||
(charge) => charge.subscription_id === subscription.id && charge.status !== 'succeeded',
|
(charge) => charge.subscription_id === subscription.id && charge.status !== 'succeeded',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCancellationDate(subscription) {
|
||||||
|
const charge = getPyroCharge(subscription)
|
||||||
|
if (!charge) return null
|
||||||
|
if (charge.status === 'cancelled') return charge.due
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const getProductSize = (product) => {
|
const getProductSize = (product) => {
|
||||||
if (!product || !product.metadata) return formatMessage(commonMessages.planUnknownLabel)
|
if (!product || !product.metadata) return formatMessage(commonMessages.planUnknownLabel)
|
||||||
const ramSize = product.metadata.ram
|
const ramSize = product.metadata.ram
|
||||||
@@ -1243,12 +1291,38 @@ const showPyroUpgradeModal = (subscription) => {
|
|||||||
upgradeModal.value?.open(subscription?.metadata?.id)
|
upgradeModal.value?.open(subscription?.metadata?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHARGE_POLL_INTERVAL_MS = 20_000
|
||||||
|
|
||||||
|
const hasProvisioningSubscription = computed(() =>
|
||||||
|
pyroSubscriptions.value?.some((s) => s.serverInfo?.isProvisioning),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { pause: pauseChargePoll, resume: resumeChargePoll } = useIntervalFn(
|
||||||
|
() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['billing', 'payments'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['billing', 'subscriptions'] })
|
||||||
|
},
|
||||||
|
CHARGE_POLL_INTERVAL_MS,
|
||||||
|
{ immediate: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
hasProvisioningSubscription,
|
||||||
|
(isProvisioning) => {
|
||||||
|
if (isProvisioning) {
|
||||||
|
resumeChargePoll()
|
||||||
|
} else {
|
||||||
|
pauseChargePoll()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const resubscribePyro = async (subscriptionId, wasSuspended) => {
|
const resubscribePyro = async (subscriptionId, wasSuspended) => {
|
||||||
try {
|
try {
|
||||||
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
|
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
})
|
})
|
||||||
await refresh()
|
|
||||||
if (wasSuspended) {
|
if (wasSuspended) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: formatMessage(messages.pyroResubscribeRequestSubmittedTitle),
|
title: formatMessage(messages.pyroResubscribeRequestSubmittedTitle),
|
||||||
@@ -1271,6 +1345,35 @@ const resubscribePyro = async (subscriptionId, wasSuspended) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPyroResubscribeModal(subscription) {
|
||||||
|
const charge = getPyroCharge(subscription)
|
||||||
|
const product = getPyroProduct(subscription)
|
||||||
|
const interval = charge?.subscription_interval || subscription?.interval
|
||||||
|
const productPrice = getProductPrice(product, interval)
|
||||||
|
|
||||||
|
pyroResubscribeModal.value?.show({
|
||||||
|
subscriptionId: subscription?.id ?? '',
|
||||||
|
wasSuspended: charge?.due ? new Date(charge.due).getTime() < Date.now() : false,
|
||||||
|
serverName: subscription?.serverInfo?.name ?? 'this server',
|
||||||
|
planName: `${getProductSize(product)} plan`,
|
||||||
|
ramGb: product?.metadata?.ram ? product.metadata.ram / 1024 : undefined,
|
||||||
|
storageGb: product?.metadata?.storage ? product.metadata.storage / 1024 : undefined,
|
||||||
|
sharedCpus: product?.metadata?.cpu ? product.metadata.cpu / 2 : undefined,
|
||||||
|
priceCents: charge?.amount ?? productPrice?.prices?.intervals?.[interval],
|
||||||
|
currencyCode: charge?.currency_code ?? productPrice?.currency_code,
|
||||||
|
interval,
|
||||||
|
nextChargeDate: charge?.due,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePyroResubscribeConfirm({ subscriptionId, wasSuspended }) {
|
||||||
|
return resubscribePyro(subscriptionId, wasSuspended)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupDownloadForServer(serverInfo) {
|
||||||
|
return getLatestBackupDownload(serverInfo.server_id, serverFullList.value)
|
||||||
|
}
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['billing'] }),
|
queryClient.invalidateQueries({ queryKey: ['billing'] }),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user