refactor: migrate to common eslint+prettier configs (#4168)

* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
This commit is contained in:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions
@@ -1,11 +1,11 @@
<template>
<div class="flex h-full w-full flex-col">
<div
class="flex items-center justify-between gap-2 border-0 border-b border-solid border-bg-raised p-3"
>
<h2 class="m-0 text-2xl font-bold text-contrast">Admin</h2>
</div>
</div>
<div class="flex h-full w-full flex-col">
<div
class="flex items-center justify-between gap-2 border-0 border-b border-solid border-bg-raised p-3"
>
<h2 class="m-0 text-2xl font-bold text-contrast">Admin</h2>
</div>
</div>
</template>
<script setup lang="ts"></script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,347 +1,348 @@
<template>
<div
v-if="server.moduleErrors.backups"
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 backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.backups.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<BackupCreateModal ref="createBackupModal" :server="server" />
<BackupRenameModal ref="renameBackupModal" :server="server" />
<BackupRestoreModal ref="restoreBackupModal" :server="server" />
<BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
<BackupSettingsModal ref="backupSettingsModal" :server="server" />
<div
v-if="server.moduleErrors.backups"
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 backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.backups.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<BackupCreateModal ref="createBackupModal" :server="server" />
<BackupRenameModal ref="renameBackupModal" :server="server" />
<BackupRestoreModal ref="restoreBackupModal" :server="server" />
<BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
<BackupSettingsModal ref="backupSettingsModal" :server="server" />
<div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
<TagItem
v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
class="cursor-help"
:style="{
'--_color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange)'
: undefined,
'--_bg-color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red-bg)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange-bg)'
: undefined,
}"
>
{{ data.used_backup_quota }} / {{ data.backup_quota }}
</TagItem>
</div>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
</p>
</div>
<div
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
>
<ButtonStyled type="standard">
<button
v-tooltip="
'Auto backups are currently unavailable; we apologize for the inconvenience.'
"
:disabled="true || server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="backupCreationDisabled"
class="w-full sm:w-fit"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
<div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
<TagItem
v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
class="cursor-help"
:style="{
'--_color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange)'
: undefined,
'--_bg-color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red-bg)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange-bg)'
: undefined,
}"
>
{{ data.used_backup_quota }} / {{ data.backup_quota }}
</TagItem>
</div>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
</p>
</div>
<div
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
>
<ButtonStyled type="standard">
<button
v-tooltip="
'Auto backups are currently unavailable; we apologize for the inconvenience.'
"
:disabled="true || server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="backupCreationDisabled"
class="w-full sm:w-fit"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
<div class="flex w-full flex-col gap-2">
<div
v-if="backups.length === 0"
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="data.used_backup_quota">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else> You don't have any backups yet. </template>
</div>
<BackupItem
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@prepare="() => prepareDownload(backup.id)"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@lock="
() => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
}
"
@delete="
(skipConfirmation?: boolean) =>
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</div>
<div class="flex w-full flex-col gap-2">
<div
v-if="backups.length === 0"
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="data.used_backup_quota">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else> You don't have any backups yet. </template>
</div>
<BackupItem
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@prepare="() => prepareDownload(backup.id)"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@lock="
() => {
if (backup.locked) {
unlockBackup(backup.id)
} else {
lockBackup(backup.id)
}
}
"
@delete="
(skipConfirmation?: boolean) =>
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon, IssuesIcon, PlusIcon, SettingsIcon, SpinnerIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager, TagItem } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import { useStorage } from "@vueuse/core";
import { computed, ref } from "vue";
import BackupCreateModal from "~/components/ui/servers/BackupCreateModal.vue";
import BackupDeleteModal from "~/components/ui/servers/BackupDeleteModal.vue";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import BackupRenameModal from "~/components/ui/servers/BackupRenameModal.vue";
import BackupRestoreModal from "~/components/ui/servers/BackupRestoreModal.vue";
import BackupSettingsModal from "~/components/ui/servers/BackupSettingsModal.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { DownloadIcon, IssuesIcon, PlusIcon, SettingsIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, TagItem } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
const { addNotification } = injectNotificationManager();
import BackupCreateModal from '~/components/ui/servers/BackupCreateModal.vue'
import BackupDeleteModal from '~/components/ui/servers/BackupDeleteModal.vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
import BackupRenameModal from '~/components/ui/servers/BackupRenameModal.vue'
import BackupRestoreModal from '~/components/ui/servers/BackupRestoreModal.vue'
import BackupSettingsModal from '~/components/ui/servers/BackupSettingsModal.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
isServerRunning: boolean;
}>();
server: ModrinthServer
isServerRunning: boolean
}>()
const route = useNativeRoute();
const serverId = route.params.id;
const route = useNativeRoute()
const serverId = route.params.id
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
backupWhileRunning: false,
});
backupWhileRunning: false,
})
defineEmits(["onDownload"]);
defineEmits(['onDownload'])
const data = computed(() => props.server.general);
const data = computed(() => props.server.general)
const backups = computed(() => {
if (!props.server.backups?.data) return [];
if (!props.server.backups?.data) return []
return [...props.server.backups.data].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
});
return [...props.server.backups.data].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
useHead({
title: `Backups - ${data.value?.name ?? "Server"} - Modrinth`,
});
title: `Backups - ${data.value?.name ?? 'Server'} - Modrinth`,
})
const overTheTopDownloadAnimation = ref();
const overTheTopDownloadAnimation = ref()
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>();
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>();
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>();
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>();
const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>();
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>()
const backupCreationDisabled = computed(() => {
if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
return "Cannot create backup while server is running";
}
if (
data.value?.used_backup_quota !== undefined &&
data.value?.backup_quota !== undefined &&
data.value?.used_backup_quota >= data.value?.backup_quota
) {
return `All ${data.value.backup_quota} of your backup slots are in use`;
}
if (backups.value.some((backup) => backup.task?.create?.state === "ongoing")) {
return "A backup is already in progress";
}
if (props.server.general?.status === "installing") {
return "Cannot create backup while server is installing";
}
return undefined;
});
if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
return 'Cannot create backup while server is running'
}
if (
data.value?.used_backup_quota !== undefined &&
data.value?.backup_quota !== undefined &&
data.value?.used_backup_quota >= data.value?.backup_quota
) {
return `All ${data.value.backup_quota} of your backup slots are in use`
}
if (backups.value.some((backup) => backup.task?.create?.state === 'ongoing')) {
return 'A backup is already in progress'
}
if (props.server.general?.status === 'installing') {
return 'Cannot create backup while server is installing'
}
return undefined
})
const showCreateModel = () => {
createBackupModal.value?.show();
};
createBackupModal.value?.show()
}
const showbackupSettingsModal = () => {
backupSettingsModal.value?.show();
};
backupSettingsModal.value?.show()
}
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true;
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500);
overTheTopDownloadAnimation.value = true
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
const prepareDownload = async (backupId: string) => {
try {
await props.server.backups?.prepare(backupId);
} catch (error) {
console.error("Failed to prepare download:", error);
addNotification({
type: "error",
title: "Failed to prepare backup for download",
text: error as string,
});
}
};
try {
await props.server.backups?.prepare(backupId)
} catch (error) {
console.error('Failed to prepare download:', error)
addNotification({
type: 'error',
title: 'Failed to prepare backup for download',
text: error as string,
})
}
}
const lockBackup = async (backupId: string) => {
try {
await props.server.backups?.lock(backupId);
await props.server.refresh(["backups"]);
} catch (error) {
console.error("Failed to toggle lock:", error);
}
};
try {
await props.server.backups?.lock(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to toggle lock:', error)
}
}
const unlockBackup = async (backupId: string) => {
try {
await props.server.backups?.unlock(backupId);
await props.server.refresh(["backups"]);
} catch (error) {
console.error("Failed to toggle lock:", error);
}
};
try {
await props.server.backups?.unlock(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to toggle lock:', error)
}
}
const retryBackup = async (backupId: string) => {
try {
await props.server.backups?.retry(backupId);
await props.server.refresh(["backups"]);
} catch (error) {
console.error("Failed to retry backup:", error);
}
};
try {
await props.server.backups?.retry(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to retry backup:', error)
}
}
async function deleteBackup(backup?: Backup) {
if (!backup) {
addNotification({
type: "error",
title: "Error deleting backup",
text: "Backup is null",
});
return;
}
if (!backup) {
addNotification({
type: 'error',
title: 'Error deleting backup',
text: 'Backup is null',
})
return
}
try {
await props.server.backups?.delete(backup.id);
await props.server.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addNotification({
type: "error",
title: "Error deleting backup",
text: message,
});
}
try {
await props.server.backups?.delete(backup.id)
await props.server.refresh()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({
type: 'error',
title: 'Error deleting backup',
text: message,
})
}
}
</script>
<style scoped>
.over-the-top-download-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
&.animation-hidden {
scale: 0.8;
opacity: 0;
&.animation-hidden {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
</style>
@@ -1,21 +1,21 @@
<template>
<div class="flex h-full w-full flex-col">
<NuxtPage :route="route" :server="props.server" />
</div>
<div class="flex h-full w-full flex-col">
<NuxtPage :route="route" :server="props.server" />
</div>
</template>
<script setup lang="ts">
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const route = useNativeRoute();
const route = useNativeRoute()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const data = computed(() => props.server.general);
const data = computed(() => props.server.general)
useHead({
title: `Content - ${data.value?.name ?? "Server"} - Modrinth`,
});
title: `Content - ${data.value?.name ?? 'Server'} - Modrinth`,
})
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,69 +1,70 @@
<template>
<UiServersServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
:backup-in-progress="backupInProgress"
/>
<UiServersServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
:backup-in-progress="backupInProgress"
/>
</template>
<script setup lang="ts">
import {
InfoIcon,
ListIcon,
SettingsIcon,
TextQuoteIcon,
VersionIcon,
CardIcon,
UserIcon,
WrenchIcon,
ModrinthIcon,
} from "@modrinth/assets";
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
CardIcon,
InfoIcon,
ListIcon,
ModrinthIcon,
SettingsIcon,
TextQuoteIcon,
UserIcon,
VersionIcon,
WrenchIcon,
} from '@modrinth/assets'
import { isAdmin as isUserAdmin, type User } from '@modrinth/utils'
const route = useRoute();
const serverId = route.params.id as string;
const auth = await useAuth();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const route = useRoute()
const serverId = route.params.id as string
const auth = await useAuth()
const props = defineProps<{
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
useHead({
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
});
title: `Options - ${props.server.general?.name ?? 'Server'} - Modrinth`,
})
const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost");
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value);
const isAdmin = computed(() => isUserAdmin(auth.value?.user));
const ownerId = computed(() => props.server.general?.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: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
{ icon: VersionIcon, label: "Network", href: `/servers/manage/${serverId}/options/network` },
{ icon: ListIcon, label: "Properties", href: `/servers/manage/${serverId}/options/properties` },
{
icon: UserIcon,
label: "Preferences",
href: `/servers/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: `/servers/manage/${serverId}/options/info` },
]);
{ icon: SettingsIcon, label: 'General', href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: 'Platform', href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: 'Startup', href: `/servers/manage/${serverId}/options/startup` },
{ icon: VersionIcon, label: 'Network', href: `/servers/manage/${serverId}/options/network` },
{ icon: ListIcon, label: 'Properties', href: `/servers/manage/${serverId}/options/properties` },
{
icon: UserIcon,
label: 'Preferences',
href: `/servers/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: `/servers/manage/${serverId}/options/info` },
])
</script>
@@ -1,12 +1,12 @@
<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>
<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";
import { ButtonStyled } from '@modrinth/ui'
</script>
@@ -1,30 +1,30 @@
<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">
<input
id="server-name-field"
v-model="serverName"
class="w-full md:w-[50%]"
maxlength="48"
minlength="1"
@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="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">
<input
id="server-name-field"
v-model="serverName"
class="w-full md:w-[50%]"
maxlength="48"
minlength="1"
@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>
@@ -36,294 +36,293 @@
</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%]">
<input
id="server-subdomain"
v-model="serverSubdomain"
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 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%]">
<input
id="server-subdomain"
v-model="serverSubdomain"
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 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-[6rem] 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>
<UiServersServerIcon :image="icon" />
</div>
<ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
<TransferIcon class="h-6 w-6" />
<span>Sync icon</span>
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else />
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
:save="saveGeneral"
:reset="resetGeneral"
/>
</div>
<div 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-[6rem] 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>
<UiServersServerIcon :image="icon" />
</div>
<ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
<TransferIcon class="h-6 w-6" />
<span>Sync icon</span>
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else />
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
:save="saveGeneral"
:reset="resetGeneral"
/>
</div>
</template>
<script setup lang="ts">
import { EditIcon, TransferIcon } from "@modrinth/assets";
import { injectNotificationManager } from "@modrinth/ui";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { EditIcon, TransferIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const data = computed(() => props.server.general);
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 = computed(() => data.value?.image);
const data = computed(() => props.server.general)
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 = computed(() => data.value?.image)
const isUpdating = ref(false);
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);
() =>
(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;
}
});
if (!isValidServerName.value) {
serverName.value = oldValue
}
})
const saveGeneral = async () => {
if (!isValidServerName.value || !isValidSubdomain.value) return;
if (!isValidServerName.value || !isValidSubdomain.value) return
try {
isUpdating.value = true;
if (serverName.value !== data.value?.name) {
await data.value?.updateName(serverName.value ?? "");
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const available = await props.server.network?.checkSubdomainAvailability(
serverSubdomain.value,
);
try {
isUpdating.value = true
if (serverName.value !== data.value?.name) {
await data.value?.updateName(serverName.value ?? '')
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const available = await props.server.network?.checkSubdomainAvailability(
serverSubdomain.value,
)
if (!available) {
addNotification({
type: "error",
title: "Subdomain not available",
text: "The subdomain you entered is already in use.",
});
return;
}
if (!available) {
addNotification({
type: 'error',
title: 'Subdomain not available',
text: 'The subdomain you entered is already in use.',
})
return
}
await props.server.network?.changeSubdomain(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 props.server.refresh();
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;
}
};
await props.server.network?.changeSubdomain(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 props.server.refresh()
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 ?? "";
};
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 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);
});
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 (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
}
try {
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
}
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
await props.server.fs?.uploadFile("/server-icon-original.png", file);
await props.server.fs?.uploadFile('/server-icon.png', scaledFile)
await props.server.fs?.uploadFile('/server-icon-original.png', file)
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-${props.server.serverId}`).value = dataURL;
if (data.value) data.value.image = dataURL;
resolve();
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
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-${props.server.serverId}`).value = dataURL
if (data.value) data.value.image = 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.",
});
}
};
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 (data.value?.image) {
try {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
if (data.value?.image) {
try {
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
useState(`server-icon-${props.server.serverId}`).value = undefined;
if (data.value) data.value.image = undefined;
useState(`server-icon-${props.server.serverId}`).value = undefined
if (data.value) data.value.image = undefined
await props.server.refresh(["general"]);
await props.server.refresh(['general'])
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.",
});
}
}
};
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();
};
e.preventDefault()
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault();
};
e.preventDefault()
}
const onDrop = (e: DragEvent) => {
e.preventDefault();
uploadFile(e);
};
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();
};
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 +1,157 @@
<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>
<button
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
class="!w-full sm:!w-auto"
@click="openSftp"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</button>
</ButtonStyled>
</div>
<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>
<button
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
class="!w-full sm:!w-auto"
@click="openSftp"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</button>
</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>
<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>
<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 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>
<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>
<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, injectNotificationManager } from "@modrinth/ui";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const data = computed(() => props.server.general);
const showPassword = ref(false);
const data = computed(() => props.server.general)
const showPassword = ref(false)
const openSftp = () => {
const sftpUrl = `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`;
window.open(sftpUrl, "_blank");
};
const sftpUrl = `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`
window.open(sftpUrl, '_blank')
}
const togglePassword = () => {
showPassword.value = !showPassword.value;
};
showPassword.value = !showPassword.value
}
const copyToClipboard = (name: string, textToCopy?: string) => {
navigator.clipboard.writeText(textToCopy || "");
addNotification({
type: "success",
title: `${name} copied to clipboard!`,
});
};
navigator.clipboard.writeText(textToCopy || '')
addNotification({
type: 'success',
title: `${name} copied to clipboard!`,
})
}
const properties = [
{ name: "Server ID", value: props.server.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" },
];
{ name: 'Server ID', value: props.server.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,22 +1,22 @@
<template>
<ServerInstallation
:server="props.server"
:backup-in-progress="props.backupInProgress"
@reinstall="emit('reinstall')"
/>
<ServerInstallation
:server="props.server"
:backup-in-progress="props.backupInProgress"
@reinstall="emit('reinstall')"
/>
</template>
<script setup lang="ts">
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const props = defineProps<{
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
</script>
@@ -1,492 +1,490 @@
<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>
<input
id="new-allocation-name"
ref="newAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
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>
<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>
<input
id="new-allocation-name"
ref="newAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
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>
<input
id="edit-allocation-name"
ref="editAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
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>
<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>
<input
id="edit-allocation-name"
ref="editAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
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"
/>
<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="server.moduleErrors.network"
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">{{
JSON.stringify(server.moduleErrors.network.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<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>
<div class="relative h-full w-full overflow-y-auto">
<div
v-if="server.moduleErrors.network"
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">{{
JSON.stringify(server.moduleErrors.network.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<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>
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="userDomain == ''"
@click="exportDnsRecords"
>
<UploadIcon />
<span>Export DNS records</span>
</button>
</ButtonStyled>
</div>
<input
id="user-domain"
v-model="userDomain"
class="w-full md:w-[50%]"
maxlength="64"
minlength="1"
type="text"
:placeholder="exampleDomain"
/>
<input
id="user-domain"
v-model="userDomain"
class="w-full md:w-[50%]"
maxlength="64"
minlength="1"
type="text"
: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 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>
<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>
<!-- 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>
<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>
<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>
<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
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>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
:is-updating="isUpdating"
:save="saveNetwork"
:reset="resetNetwork"
/>
</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>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
: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";
EditIcon,
InfoIcon,
IssuesIcon,
PlusIcon,
SaveIcon,
TrashIcon,
UploadIcon,
VersionIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
CopyCode,
injectNotificationManager,
NewModal,
} from "@modrinth/ui";
import { computed, nextTick, ref } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
ButtonStyled,
ConfirmModal,
CopyCode,
injectNotificationManager,
NewModal,
} from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const isUpdating = ref(false);
const data = computed(() => props.server.general);
const isUpdating = ref(false)
const data = computed(() => props.server.general)
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 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 network = computed(() => props.server.network);
const allocations = computed(() => network.value?.allocations);
const network = computed(() => props.server.network)
const allocations = computed(() => network.value?.allocations)
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 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 hasUnsavedChanges = computed(() => serverSubdomain.value !== data?.value?.net?.domain)
const isValidSubdomain = computed(() => /^[a-zA-Z0-9-]{5,}$/.test(serverSubdomain.value));
const isValidSubdomain = computed(() => /^[a-zA-Z0-9-]{5,}$/.test(serverSubdomain.value))
const addNewAllocation = async () => {
if (!newAllocationName.value) return;
if (!newAllocationName.value) return
try {
await props.server.network?.reserveAllocation(newAllocationName.value);
await props.server.refresh(["network"]);
try {
await props.server.network?.reserveAllocation(newAllocationName.value)
await props.server.refresh(['network'])
newAllocationModal.value?.hide();
newAllocationName.value = "";
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);
}
};
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);
});
};
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);
});
};
newAllocationPort.value = port
editAllocationModal.value?.show()
nextTick(() => {
setTimeout(() => {
editAllocationInput.value?.focus()
}, 100)
})
}
const showConfirmDeleteModal = (port: number) => {
allocationToDelete.value = port;
confirmDeleteModal.value?.show();
};
allocationToDelete.value = port
confirmDeleteModal.value?.show()
}
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return;
if (allocationToDelete.value === null) return
await props.server.network?.deleteAllocation(allocationToDelete.value);
await props.server.refresh(["network"]);
await props.server.network?.deleteAllocation(allocationToDelete.value)
await props.server.refresh(['network'])
addNotification({
type: "success",
title: "Allocation removed",
text: "Your allocation has been removed.",
});
addNotification({
type: 'success',
title: 'Allocation removed',
text: 'Your allocation has been removed.',
})
allocationToDelete.value = null;
};
allocationToDelete.value = null
}
const editAllocation = async () => {
if (!newAllocationName.value) return;
if (!newAllocationName.value) return
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
await props.server.refresh(["network"]);
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value)
await props.server.refresh(['network'])
editAllocationModal.value?.hide();
newAllocationName.value = "";
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);
}
};
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;
if (!isValidSubdomain.value) return
try {
isUpdating.value = true;
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value);
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 props.server.network?.changeSubdomain(serverSubdomain.value);
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await props.server.network?.updateAllocation(
serverPrimaryPort.value,
newAllocationName.value,
);
}
await new Promise((resolve) => setTimeout(resolve, 500));
await props.server.refresh();
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;
}
};
try {
isUpdating.value = true
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value)
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 props.server.network?.changeSubdomain(serverSubdomain.value)
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value)
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
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 ?? "";
};
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 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 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 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`,
});
};
navigator.clipboard.writeText(text)
addNotification({
type: 'success',
title: 'Text copied',
text: `${text} has been copied to your clipboard`,
})
}
</script>
@@ -1,129 +1,130 @@
<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>
<input
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="switch stylized-toggle flex-none"
type="checkbox"
:disabled="prefConfig.implemented === false"
/>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
/>
</div>
<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>
<input
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="switch stylized-toggle flex-none"
type="checkbox"
:disabled="prefConfig.implemented === false"
/>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
/>
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from "@modrinth/ui";
import { useStorage } from "@vueuse/core";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { injectNotificationManager } from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
const { addNotification } = injectNotificationManager();
const route = useNativeRoute();
const serverId = route.params.id as string;
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const route = useNativeRoute()
const serverId = route.params.id as string
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
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,
},
backupWhileRunning: {
displayName: "Create backups while running",
description: "When enabled, backups will be created even if the server is running.",
implemented: true,
},
} as const;
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,
},
backupWhileRunning: {
displayName: 'Create backups while running',
description: 'When enabled, backups will be created even if the server is running.',
implemented: true,
},
} as const
type PreferenceKeys = keyof typeof preferences;
type PreferenceKeys = keyof typeof preferences
type UserPreferences = {
[K in PreferenceKeys]: boolean;
};
[K in PreferenceKeys]: boolean
}
const defaultPreferences: UserPreferences = {
ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,
};
ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,
}
const userPreferences = useStorage<UserPreferences>(
`pyro-server-${serverId}-preferences`,
defaultPreferences,
);
`pyro-server-${serverId}-preferences`,
defaultPreferences,
)
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)));
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)))
const hasUnsavedChanges = computed(() => {
return JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value);
});
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.",
});
};
userPreferences.value = { ...newUserPreferences.value }
addNotification({
type: 'success',
title: 'Preferences saved',
text: 'Your preferences have been saved.',
})
}
const resetPreferences = () => {
newUserPreferences.value = { ...userPreferences.value };
};
newUserPreferences.value = { ...userPreferences.value }
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>
@@ -1,332 +1,333 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="server.moduleErrors.fs"
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 properties</h1>
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.fs.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="server.moduleErrors.fs"
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 properties</h1>
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.fs.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-else-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<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="relative w-full text-sm">
<label for="search-server-properties" class="sr-only">Search server properties</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-server-properties"
v-model="searchInput"
class="w-full pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div
v-for="(property, index) in filteredProperties"
:key="index"
class="flex flex-row flex-wrap items-center justify-between py-2"
>
<div class="flex items-center">
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
<EyeIcon v-tooltip="overrides[index].info" />
</span>
</div>
<div
v-if="overrides[index] && overrides[index].type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<UiServersTeleportDropdownMenu
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
:options="overrides[index].options || []"
:aria-labelledby="`property-label-${index}`"
placeholder="Select..."
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="switch stylized-toggle"
type="checkbox"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="typeof property === 'number'" class="mt-2 w-full sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model.number="liveProperties[index]"
type="number"
class="w-full border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<textarea
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="w-full resize-y rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
></textarea>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
type="text"
class="w-full rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card flex h-full w-full items-center justify-center">
<p class="text-contrast">
The server properties file has not been generated yet. Start up your server to generate it.
</p>
</div>
<div
v-else-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<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="relative w-full text-sm">
<label for="search-server-properties" class="sr-only">Search server properties</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-server-properties"
v-model="searchInput"
class="w-full pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div
v-for="(property, index) in filteredProperties"
:key="index"
class="flex flex-row flex-wrap items-center justify-between py-2"
>
<div class="flex items-center">
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
<EyeIcon v-tooltip="overrides[index].info" />
</span>
</div>
<div
v-if="overrides[index] && overrides[index].type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<UiServersTeleportDropdownMenu
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
:options="overrides[index].options || []"
:aria-labelledby="`property-label-${index}`"
placeholder="Select..."
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="switch stylized-toggle"
type="checkbox"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="typeof property === 'number'" class="mt-2 w-full sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model.number="liveProperties[index]"
type="number"
class="w-full border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<textarea
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="w-full resize-y rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
></textarea>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
type="text"
class="w-full rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card flex h-full w-full items-center justify-center">
<p class="text-contrast">
The server properties file has not been generated yet. Start up your server to generate it.
</p>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
restart
:save="saveProperties"
:reset="resetProperties"
/>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
restart
:save="saveProperties"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, IssuesIcon, SearchIcon } from "@modrinth/assets";
import { injectNotificationManager } from "@modrinth/ui";
import Fuse from "fuse.js";
import { computed, inject, ref, watch } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { EyeIcon, IssuesIcon, SearchIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, inject, ref, watch } from 'vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const tags = useTags();
const tags = useTags()
const isUpdating = ref(false);
const isUpdating = ref(false)
const searchInput = ref("");
const searchInput = ref('')
const data = computed(() => props.server.general);
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
await modulesLoaded;
const rawProps = await props.server.fs?.downloadFile("server.properties");
if (!rawProps) return null;
const data = computed(() => props.server.general)
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
await modulesLoaded
const rawProps = await props.server.fs?.downloadFile('server.properties')
if (!rawProps) return null
const properties: Record<string, any> = {};
const lines = rawProps.split("\n");
const properties: Record<string, any> = {}
const lines = rawProps.split('\n')
for (const line of lines) {
if (line.startsWith("#") || !line.includes("=")) continue;
const [key, ...valueParts] = line.split("=");
let value = valueParts.join("=");
for (const line of lines) {
if (line.startsWith('#') || !line.includes('=')) continue
const [key, ...valueParts] = line.split('=')
let value = valueParts.join('=')
if (value.toLowerCase() === "true" || value.toLowerCase() === "false") {
value = value.toLowerCase() === "true";
} else if (!isNaN(value as any) && value !== "") {
value = Number(value);
}
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
value = value.toLowerCase() === 'true'
} else if (!isNaN(value as any) && value !== '') {
value = Number(value)
}
properties[key.trim()] = value;
}
properties[key.trim()] = value
}
return properties;
});
return properties
})
const liveProperties = ref<Record<string, any>>({});
const originalProperties = ref<Record<string, any>>({});
const liveProperties = ref<Record<string, any>>({})
const originalProperties = ref<Record<string, any>>({})
watch(
propsData,
(newPropsData) => {
if (newPropsData) {
liveProperties.value = JSON.parse(JSON.stringify(newPropsData));
originalProperties.value = JSON.parse(JSON.stringify(newPropsData));
}
},
{ immediate: true },
);
propsData,
(newPropsData) => {
if (newPropsData) {
liveProperties.value = JSON.parse(JSON.stringify(newPropsData))
originalProperties.value = JSON.parse(JSON.stringify(newPropsData))
}
},
{ immediate: true },
)
const hasUnsavedChanges = computed(() => {
return Object.keys(liveProperties.value).some(
(key) =>
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
);
});
return Object.keys(liveProperties.value).some(
(key) =>
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
)
})
const getDifficultyOptions = () => {
const pre113Versions = tags.value.gameVersions
.filter((v) => {
const versionNumbers = v.version.split(".").map(Number);
return versionNumbers[0] === 1 && versionNumbers[1] < 13;
})
.map((v) => v.version);
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
return ["0", "1", "2", "3"];
} else {
return ["peaceful", "easy", "normal", "hard"];
}
};
const pre113Versions = tags.value.gameVersions
.filter((v) => {
const versionNumbers = v.version.split('.').map(Number)
return versionNumbers[0] === 1 && versionNumbers[1] < 13
})
.map((v) => v.version)
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
return ['0', '1', '2', '3']
} else {
return ['peaceful', 'easy', 'normal', 'hard']
}
}
const overrides: { [key: string]: { type: string; options?: string[]; info?: string } } = {
difficulty: {
type: "dropdown",
options: getDifficultyOptions(),
},
gamemode: {
type: "dropdown",
options: ["survival", "creative", "adventure", "spectator"],
},
};
difficulty: {
type: 'dropdown',
options: getDifficultyOptions(),
},
gamemode: {
type: 'dropdown',
options: ['survival', 'creative', 'adventure', 'spectator'],
},
}
const fuse = computed(() => {
if (!liveProperties.value) return null;
if (!liveProperties.value) return null
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}));
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}))
return new Fuse(propertiesToFuse, {
keys: ["key", "value"],
threshold: 0.2,
});
});
return new Fuse(propertiesToFuse, {
keys: ['key', 'value'],
threshold: 0.2,
})
})
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) {
return liveProperties.value;
}
if (!searchInput.value?.trim()) {
return liveProperties.value
}
const results = fuse.value?.search(searchInput.value) ?? [];
const results = fuse.value?.search(searchInput.value) ?? []
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]));
});
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
})
const constructServerProperties = (): string => {
const properties = liveProperties.value;
const properties = liveProperties.value
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
for (const [key, value] of Object.entries(properties)) {
if (typeof value === "object") {
fileContent += `${key}=${JSON.stringify(value)}\n`;
} else if (typeof value === "boolean") {
fileContent += `${key}=${value ? "true" : "false"}\n`;
} else {
fileContent += `${key}=${value}\n`;
}
}
for (const [key, value] of Object.entries(properties)) {
if (typeof value === 'object') {
fileContent += `${key}=${JSON.stringify(value)}\n`
} else if (typeof value === 'boolean') {
fileContent += `${key}=${value ? 'true' : 'false'}\n`
} else {
fileContent += `${key}=${value}\n`
}
}
return fileContent;
};
return fileContent
}
const saveProperties = async () => {
try {
isUpdating.value = true;
await props.server.fs?.updateFile("server.properties", constructServerProperties());
await new Promise((resolve) => setTimeout(resolve, 500));
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value));
await props.server.refresh();
addNotification({
type: "success",
title: "Server properties updated",
text: "Your server properties were successfully changed.",
});
} catch (error) {
console.error("Error updating server properties:", error);
addNotification({
type: "error",
title: "Failed to update server properties",
text: "An error occurred while attempting to update your server properties.",
});
} finally {
isUpdating.value = false;
}
};
try {
isUpdating.value = true
await props.server.fs?.updateFile('server.properties', constructServerProperties())
await new Promise((resolve) => setTimeout(resolve, 500))
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
await props.server.refresh()
addNotification({
type: 'success',
title: 'Server properties updated',
text: 'Your server properties were successfully changed.',
})
} catch (error) {
console.error('Error updating server properties:', error)
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: 'An error occurred while attempting to update your server properties.',
})
} finally {
isUpdating.value = false
}
}
const resetProperties = async () => {
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value));
await new Promise((resolve) => setTimeout(resolve, 200));
};
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value))
await new Promise((resolve) => setTimeout(resolve, 200))
}
const formatPropertyName = (propertyName: string): string => {
return propertyName
.split(/[-.]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
return propertyName
.split(/[-.]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const isComplexProperty = (property: any): boolean => {
return (
typeof property === "object" ||
(typeof property === "string" &&
(property.includes(",") ||
property.includes("{") ||
property.includes("}") ||
property.includes("[") ||
property.includes("]") ||
property.length > 30))
);
};
return (
typeof property === 'object' ||
(typeof property === 'string' &&
(property.includes(',') ||
property.includes('{') ||
property.includes('}') ||
property.includes('[') ||
property.includes(']') ||
property.length > 30))
)
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>
@@ -1,234 +1,235 @@
<template>
<div class="relative h-full w-full">
<div
v-if="server.moduleErrors.startup"
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 startup settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.startup.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<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 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="relative h-full w-full">
<div
v-if="server.moduleErrors.startup"
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 startup settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.startup.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<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 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="invocation === originalInvocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Restore default command
</button>
</ButtonStyled>
</div>
<textarea
id="startup-command-field"
v-model="invocation"
class="min-h-[270px] w-full resize-y font-[family-name:var(--mono-font)]"
/>
</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="invocation === originalInvocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Restore default command
</button>
</ButtonStyled>
</div>
<textarea
id="startup-command-field"
v-model="invocation"
class="min-h-[270px] w-full resize-y font-[family-name:var(--mono-font)]"
/>
</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="flex items-center gap-2">
<input
id="show-all-versions"
v-model="showAllVersions"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
</div>
<UiServersTeleportDropdownMenu
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
:options="displayedJavaVersions"
placeholder="Java Version"
/>
</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>
<UiServersTeleportDropdownMenu
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
:options="['Corretto', 'Temurin', 'GraalVM']"
placeholder="Runtime"
/>
</div>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:save="saveStartup"
:reset="resetStartup"
/>
</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="flex items-center gap-2">
<input
id="show-all-versions"
v-model="showAllVersions"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
</div>
<UiServersTeleportDropdownMenu
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
:options="displayedJavaVersions"
placeholder="Java Version"
/>
</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>
<UiServersTeleportDropdownMenu
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
:options="['Corretto', 'Temurin', 'GraalVM']"
placeholder="Runtime"
/>
</div>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:save="saveStartup"
:reset="resetStartup"
/>
</div>
</template>
<script setup lang="ts">
import { IssuesIcon, UpdatedIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager } from "@modrinth/ui";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
await props.server.startup.fetch();
await props.server.startup.fetch()
const data = computed(() => props.server.general);
const showAllVersions = ref(false);
const data = computed(() => props.server.general)
const showAllVersions = ref(false)
const jdkVersionMap = [
{ value: "lts8", label: "Java 8" },
{ value: "lts11", label: "Java 11" },
{ value: "lts17", label: "Java 17" },
{ value: "lts21", label: "Java 21" },
];
{ value: 'lts8', label: 'Java 8' },
{ value: 'lts11', label: 'Java 11' },
{ value: 'lts17', label: 'Java 17' },
{ value: 'lts21', label: 'Java 21' },
]
const jdkBuildMap = [
{ value: "corretto", label: "Corretto" },
{ value: "temurin", label: "Temurin" },
{ value: "graal", label: "GraalVM" },
];
{ value: 'corretto', label: 'Corretto' },
{ value: 'temurin', label: 'Temurin' },
{ value: 'graal', label: 'GraalVM' },
]
const invocation = ref(props.server.startup.invocation);
const invocation = ref(props.server.startup.invocation)
const jdkVersion = ref(
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
);
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
)
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label)
const originalInvocation = ref(invocation.value);
const originalJdkVersion = ref(jdkVersion.value);
const originalJdkBuild = ref(jdkBuild.value);
const originalInvocation = ref(invocation.value)
const originalJdkVersion = ref(jdkVersion.value)
const originalJdkBuild = ref(jdkBuild.value)
const hasUnsavedChanges = computed(
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.value,
);
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.value,
)
const isUpdating = ref(false);
const isUpdating = ref(false)
const compatibleJavaVersions = computed(() => {
const mcVersion = data.value?.mc_version ?? "";
if (!mcVersion) return jdkVersionMap.map((v) => v.label);
const mcVersion = data.value?.mc_version ?? ''
if (!mcVersion) return jdkVersionMap.map((v) => v.label)
const [major, minor] = mcVersion.split(".").map(Number);
const [major, minor] = mcVersion.split('.').map(Number)
if (major >= 1) {
if (minor >= 20) return ["Java 21"];
if (minor >= 18) return ["Java 17", "Java 21"];
if (minor >= 17) return ["Java 16", "Java 17", "Java 21"];
if (minor >= 12) return ["Java 8", "Java 11", "Java 17", "Java 21"];
if (minor >= 6) return ["Java 8", "Java 11"];
}
if (major >= 1) {
if (minor >= 20) return ['Java 21']
if (minor >= 18) return ['Java 17', 'Java 21']
if (minor >= 17) return ['Java 16', 'Java 17', 'Java 21']
if (minor >= 12) return ['Java 8', 'Java 11', 'Java 17', 'Java 21']
if (minor >= 6) return ['Java 8', 'Java 11']
}
return ["Java 8"];
});
return ['Java 8']
})
const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
});
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value
})
async function saveStartup() {
try {
isUpdating.value = true;
const invocationValue = invocation.value ?? "";
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value;
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value;
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any);
try {
isUpdating.value = true
const invocationValue = invocation.value ?? ''
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any)
await new Promise((resolve) => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10))
await props.server.refresh(["startup"]);
await props.server.refresh(['startup'])
if (props.server.startup) {
invocation.value = props.server.startup.invocation;
jdkVersion.value =
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || "";
jdkBuild.value =
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || "";
}
if (props.server.startup) {
invocation.value = props.server.startup.invocation
jdkVersion.value =
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || ''
jdkBuild.value =
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || ''
}
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 arguments",
text: "Please try again later.",
});
} finally {
isUpdating.value = false;
}
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 arguments',
text: 'Please try again later.',
})
} finally {
isUpdating.value = false
}
}
function resetStartup() {
invocation.value = originalInvocation.value;
jdkVersion.value = originalJdkVersion.value;
jdkBuild.value = originalJdkBuild.value;
invocation.value = originalInvocation.value
jdkVersion.value = originalJdkVersion.value
jdkBuild.value = originalJdkBuild.value
}
function resetToDefault() {
invocation.value = originalInvocation.value ?? "";
invocation.value = originalInvocation.value ?? ''
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>
+172 -171
View File
@@ -1,210 +1,211 @@
<template>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<div
v-if="hasError || fetchError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
>
<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-blue p-4">
<HammerIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
</div>
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
<li>
Our systems automatically alert our team when there's an issue. We are already working
on getting them back online.
</li>
<li>
If you recently purchased your Modrinth Server, it is currently in a queue and will
appear here as soon as it's ready. <br />
<span class="font-medium text-contrast"
>Do not attempt to purchase a new server.</span
>
</li>
<li>
If you require personalized support regarding the status of your server, please
contact Modrinth Support.
</li>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<div
v-if="hasError || fetchError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
>
<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-blue p-4">
<HammerIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
</div>
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
<li>
Our systems automatically alert our team when there's an issue. We are already working
on getting them back online.
</li>
<li>
If you recently purchased your Modrinth Server, it is currently in a queue and will
appear here as soon as it's ready. <br />
<span class="font-medium text-contrast"
>Do not attempt to purchase a new server.</span
>
</li>
<li>
If you require personalized support regarding the status of your server, please
contact Modrinth Support.
</li>
<li v-if="fetchError" class="text-red">
<p>Error details:</p>
<CopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:copyable="false"
:selectable="false"
:language="'json'"
/>
</li>
</ul>
</div>
<ButtonStyled size="large" type="standard" color="brand">
<a class="mt-6 !w-full" href="https://support.modrinth.com">Contact Modrinth Support</a>
</ButtonStyled>
<ButtonStyled size="large" @click="() => reloadNuxtApp()">
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<li v-if="fetchError" class="text-red">
<p>Error details:</p>
<CopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:copyable="false"
:selectable="false"
:language="'json'"
/>
</li>
</ul>
</div>
<ButtonStyled size="large" type="standard" color="brand">
<a class="mt-6 !w-full" href="https://support.modrinth.com">Contact Modrinth Support</a>
</ButtonStyled>
<ButtonStyled size="large" @click="() => reloadNuxtApp()">
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<LazyUiServersServerManageEmptyState
v-else-if="serverList.length === 0 && !isPollingForNewServers && !hasError"
/>
<LazyUiServersServerManageEmptyState
v-else-if="serverList.length === 0 && !isPollingForNewServers && !hasError"
/>
<template v-else>
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<div class="relative w-full text-sm md:w-72">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="w-full border-[1px] border-solid border-button-border pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
/>
</div>
<ButtonStyled type="standard">
<NuxtLink
class="!h-10 whitespace-pre !border-[1px] !border-solid !border-button-border text-sm !font-medium"
:to="{ path: '/servers', hash: '#plan' }"
>
<PlusIcon class="size-4" />
New server
</NuxtLink>
</ButtonStyled>
</div>
</div>
<template v-else>
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<div class="relative w-full text-sm md:w-72">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="w-full border-[1px] border-solid border-button-border pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
/>
</div>
<ButtonStyled type="standard">
<NuxtLink
class="!h-10 whitespace-pre !border-[1px] !border-solid !border-button-border text-sm !font-medium"
:to="{ path: '/servers', hash: '#plan' }"
>
<PlusIcon class="size-4" />
New server
</NuxtLink>
</ButtonStyled>
</div>
</div>
<ul
v-if="filteredData.length > 0 || isPollingForNewServers"
class="m-0 flex flex-col gap-4 p-0"
>
<UiServersServerListing
v-for="server in filteredData"
:key="server.server_id"
v-bind="server"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
<div v-else class="flex h-full items-center justify-center">
<p class="text-contrast">No servers found.</p>
</div>
</template>
</div>
<ul
v-if="filteredData.length > 0 || isPollingForNewServers"
class="m-0 flex flex-col gap-4 p-0"
>
<UiServersServerListing
v-for="server in filteredData"
:key="server.server_id"
v-bind="server"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
<div v-else class="flex h-full items-center justify-center">
<p class="text-contrast">No servers found.</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import Fuse from "fuse.js";
import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets";
import { ButtonStyled, CopyCode } from "@modrinth/ui";
import type { Server, ModrinthServersFetchError } from "@modrinth/utils";
import { reloadNuxtApp } from "#app";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { HammerIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode } from '@modrinth/ui'
import type { ModrinthServersFetchError, Server } from '@modrinth/utils'
import Fuse from 'fuse.js'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { reloadNuxtApp } from '#app'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
useHead({
title: "Servers - Modrinth",
});
title: 'Servers - Modrinth',
})
interface ServerResponse {
servers: Server[];
servers: Server[]
}
const router = useRouter();
const route = useRoute();
const hasError = ref(false);
const isPollingForNewServers = ref(false);
const router = useRouter()
const route = useRoute()
const hasError = ref(false)
const isPollingForNewServers = ref(false)
const {
data: serverResponse,
error: fetchError,
refresh,
} = await useAsyncData<ServerResponse>("ServerList", () =>
useServersFetch<ServerResponse>("servers"),
);
data: serverResponse,
error: fetchError,
refresh,
} = await useAsyncData<ServerResponse>('ServerList', () =>
useServersFetch<ServerResponse>('servers'),
)
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response;
});
hasError.value = !!error || !response
})
const serverList = computed(() => {
if (!serverResponse.value) return [];
return serverResponse.value.servers;
});
if (!serverResponse.value) return []
return serverResponse.value.servers
})
const searchInput = ref("");
const searchInput = ref('')
const fuse = computed(() => {
if (serverList.value.length === 0) return null;
return new Fuse(serverList.value, {
keys: ["name", "loader", "mc_version", "game", "state"],
includeScore: true,
threshold: 0.4,
});
});
if (serverList.value.length === 0) return null
return new Fuse(serverList.value, {
keys: ['name', 'loader', 'mc_version', 'game', 'state'],
includeScore: true,
threshold: 0.4,
})
})
function introToTop(array: Server[]): Server[] {
return array.slice().sort((a, b) => {
return Number(b.flows?.intro) - Number(a.flows?.intro);
});
return array.slice().sort((a, b) => {
return Number(b.flows?.intro) - Number(a.flows?.intro)
})
}
const filteredData = computed(() => {
if (!searchInput.value.trim()) {
return introToTop(serverList.value);
}
return fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: [];
});
if (!searchInput.value.trim()) {
return introToTop(serverList.value)
}
return fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: []
})
const previousServerList = ref<Server[]>([]);
const refreshCount = ref(0);
const previousServerList = ref<Server[]>([])
const refreshCount = ref(0)
const checkForNewServers = async () => {
await refresh();
refreshCount.value += 1;
if (JSON.stringify(previousServerList.value) !== JSON.stringify(serverList.value)) {
isPollingForNewServers.value = false;
clearInterval(intervalId);
router.replace({ query: {} });
} else if (refreshCount.value >= 5) {
isPollingForNewServers.value = false;
clearInterval(intervalId);
}
};
await refresh()
refreshCount.value += 1
if (JSON.stringify(previousServerList.value) !== JSON.stringify(serverList.value)) {
isPollingForNewServers.value = false
clearInterval(intervalId)
router.replace({ query: {} })
} else if (refreshCount.value >= 5) {
isPollingForNewServers.value = false
clearInterval(intervalId)
}
}
let intervalId: ReturnType<typeof setInterval> | undefined;
let intervalId: ReturnType<typeof setInterval> | undefined
onMounted(() => {
if (route.query.redirect_status === "succeeded") {
isPollingForNewServers.value = true;
previousServerList.value = [...serverList.value];
intervalId = setInterval(checkForNewServers, 5000);
}
});
if (route.query.redirect_status === 'succeeded') {
isPollingForNewServers.value = true
previousServerList.value = [...serverList.value]
intervalId = setInterval(checkForNewServers, 5000)
}
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
if (intervalId) {
clearInterval(intervalId)
}
})
</script>