Merge commit 'affeec82f0f868d7e8260896584497dd4ea6465f' into feature-clean

This commit is contained in:
2025-02-10 22:16:10 +03:00
54 changed files with 3575 additions and 2778 deletions

View File

@@ -1,61 +1,128 @@
<template>
<div>
<section class="universal-card">
<h2 class="label__title size-card-header">License</h2>
<p class="label__description">
It is important to choose a proper license for your
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
list or provide a custom license. You may also provide a custom URL to your chosen license;
otherwise, the license text will be displayed. See our
<a
href="https://blog.modrinth.com/licensing-guide/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide
</a>
for more information.
</p>
<div class="adjacent-input">
<label for="license-multiselect">
<span class="label__title size-card-header">License</span>
<span class="label__title">Select a license</span>
<span class="label__description">
It is very important to choose a proper license for your
{{ $formatProjectType(project.project_type).toLowerCase() }}. You may choose one from
our list or provide a custom license. You may also provide a custom URL to your chosen
license; otherwise, the license text will be displayed.
<span v-if="license && license.friendly === 'Custom'" class="label__subdescription">
Enter a valid
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
SPDX license identifier</a
>
in the marked area. If your license does not have a SPDX identifier (for example, if
you created the license yourself or if the license is Minecraft-specific), simply
check the box and enter the name of the license instead.
</span>
<span class="label__subdescription">
Confused? See our
<a
href="https://blog.modrinth.com/licensing-guide/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide</a
>
for more information.
</span>
How users are and aren't allowed to use your project.
</span>
</label>
<div class="input-stack">
<Multiselect
id="license-multiselect"
<div class="w-1/2">
<DropdownSelect
v-model="license"
name="License selector"
:options="builtinLicenses"
:display-name="(chosen: BuiltinLicense) => chosen.friendly"
placeholder="Select license..."
track-by="short"
label="friendly"
:options="defaultLicenses"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:class="{
'known-error': license?.short === '' && showKnownErrors,
}"
/>
</div>
</div>
<div class="adjacent-input" v-if="license.requiresOnlyOrLater">
<label for="or-later-checkbox">
<span class="label__title">Later editions</span>
<span class="label__description">
The license you selected has an "or later" clause. If you check this box, users may use
your project under later editions of the license.
</span>
</label>
<Checkbox
id="or-later-checkbox"
v-model="allowOrLater"
:disabled="!hasPermission"
description="Allow later editions"
class="w-1/2"
>
Allow later editions
</Checkbox>
</div>
<div class="adjacent-input">
<label for="license-url">
<span class="label__title">License URL</span>
<span class="label__description" v-if="license?.friendly !== 'Custom'">
The web location of the full license text. If you don't provide a link, the license text
will be displayed instead.
</span>
<span class="label__description" v-else>
The web location of the full license text. You have to provide a link since this is a
custom license.
</span>
</label>
<div class="w-1/2">
<input
id="license-url"
v-model="licenseUrl"
type="url"
maxlength="2048"
:placeholder="license?.friendly !== 'Custom' ? `License URL (optional)` : `License URL`"
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
class="w-full"
/>
</div>
</div>
<div class="adjacent-input" v-if="license?.friendly === 'Custom'">
<label for="license-spdx" v-if="!nonSpdxLicense">
<span class="label__title">SPDX identifier</span>
<span class="label__description">
If your license does not have an offical
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
SPDX license identifier</a
>, check the box and enter the name of the license instead.
</span>
</label>
<label for="license-name" v-else>
<span class="label__title">License name</span>
<span class="label__description"
>The full name of the license. If the license has a SPDX identifier, please uncheck the
checkbox and use the identifier instead.</span
>
</label>
<div class="input-stack w-1/2">
<input
v-if="!nonSpdxLicense"
v-model="license.short"
id="license-spdx"
class="w-full"
type="text"
maxlength="128"
placeholder="SPDX identifier"
:disabled="!hasPermission"
/>
<Checkbox
v-if="license?.requiresOnlyOrLater"
v-model="allowOrLater"
<input
v-else
v-model="license.short"
id="license-name"
class="w-full"
type="text"
maxlength="128"
placeholder="License name"
:disabled="!hasPermission"
description="Allow later editions of this license"
>
Allow later editions of this license
</Checkbox>
/>
<Checkbox
v-if="license?.friendly === 'Custom'"
v-model="nonSpdxLicense"
@@ -64,31 +131,18 @@
>
License does not have a SPDX identifier
</Checkbox>
<input
v-if="license?.friendly === 'Custom'"
v-model="license.short"
type="text"
maxlength="2048"
:placeholder="nonSpdxLicense ? 'License name' : 'SPDX identifier'"
:class="{
'known-error': license.short === '' && showKnownErrors,
}"
:disabled="!hasPermission"
/>
<input
v-model="licenseUrl"
type="url"
maxlength="2048"
placeholder="License URL (optional)"
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
/>
</div>
</div>
<div class="input-stack">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges || license === null"
:disabled="
!hasChanges ||
!hasPermission ||
(license.friendly === 'Custom' && (license.short === '' || licenseUrl === ''))
"
@click="saveChanges()"
>
<SaveIcon />
@@ -99,199 +153,109 @@
</div>
</template>
<script>
import Multiselect from "vue-multiselect";
import Checkbox from "~/components/ui/Checkbox";
<script setup lang="ts">
import { Checkbox, DropdownSelect } from "@modrinth/ui";
import {
TeamMemberPermission,
builtinLicenses,
formatProjectType,
type BuiltinLicense,
type Project,
type TeamMember,
} from "@modrinth/utils";
import { computed, ref, type Ref } from "vue";
import SaveIcon from "~/assets/images/utils/save.svg?component";
export default defineNuxtComponent({
components: {
Multiselect,
Checkbox,
SaveIcon,
},
props: {
project: {
type: Object,
default() {
return {};
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: "main",
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
};
},
},
},
data() {
return {
licenseUrl: "",
license: { friendly: "", short: "", requiresOnlyOrLater: false },
allowOrLater: this.project.license.id.includes("-or-later"),
nonSpdxLicense: this.project.license.id.includes("LicenseRef-"),
showKnownErrors: false,
};
},
async setup(props) {
const defaultLicenses = shallowRef([
{ friendly: "Custom", short: "" },
{
friendly: "All Rights Reserved/No License",
short: "All-Rights-Reserved",
},
{ friendly: "Apache License 2.0", short: "Apache-2.0" },
{
friendly: 'BSD 2-Clause "Simplified" License',
short: "BSD-2-Clause",
},
{
friendly: 'BSD 3-Clause "New" or "Revised" License',
short: "BSD-3-Clause",
},
{
friendly: "CC Zero (Public Domain equivalent)",
short: "CC0-1.0",
},
{ friendly: "CC-BY 4.0", short: "CC-BY-4.0" },
{
friendly: "CC-BY-SA 4.0",
short: "CC-BY-SA-4.0",
},
{
friendly: "CC-BY-NC 4.0",
short: "CC-BY-NC-4.0",
},
{
friendly: "CC-BY-NC-SA 4.0",
short: "CC-BY-NC-SA-4.0",
},
{
friendly: "CC-BY-ND 4.0",
short: "CC-BY-ND-4.0",
},
{
friendly: "CC-BY-NC-ND 4.0",
short: "CC-BY-NC-ND-4.0",
},
{
friendly: "GNU Affero General Public License v3",
short: "AGPL-3.0",
requiresOnlyOrLater: true,
},
{
friendly: "GNU Lesser General Public License v2.1",
short: "LGPL-2.1",
requiresOnlyOrLater: true,
},
{
friendly: "GNU Lesser General Public License v3",
short: "LGPL-3.0",
requiresOnlyOrLater: true,
},
{
friendly: "GNU General Public License v2",
short: "GPL-2.0",
requiresOnlyOrLater: true,
},
{
friendly: "GNU General Public License v3",
short: "GPL-3.0",
requiresOnlyOrLater: true,
},
{ friendly: "ISC License", short: "ISC" },
{ friendly: "MIT License", short: "MIT" },
{ friendly: "Mozilla Public License 2.0", short: "MPL-2.0" },
{ friendly: "zlib License", short: "Zlib" },
]);
const props = defineProps<{
project: Project;
currentMember: TeamMember | undefined;
patchProject: (payload: Object, quiet?: boolean) => Object;
}>();
const licenseUrl = ref(props.project.license.url);
const licenseId = props.project.license.id;
const trimmedLicenseId = licenseId
.replaceAll("-only", "")
.replaceAll("-or-later", "")
.replaceAll("LicenseRef-", "");
const license = ref(
defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
friendly: "Custom",
short: licenseId.replaceAll("LicenseRef-", ""),
},
);
if (licenseId === "LicenseRef-Unknown") {
license.value = {
friendly: "Unknown",
short: licenseId.replaceAll("LicenseRef-", ""),
};
}
return {
defaultLicenses,
licenseUrl,
license,
};
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2;
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS;
},
licenseId() {
let id = "";
if (this.license === null) return id;
if (
(this.nonSpdxLicense && this.license.friendly === "Custom") ||
this.license.short === "All-Rights-Reserved" ||
this.license.short === "Unknown"
) {
id += "LicenseRef-";
}
id += this.license.short;
if (this.license.requiresOnlyOrLater) {
id += this.allowOrLater ? "-or-later" : "-only";
}
if (this.nonSpdxLicense && this.license.friendly === "Custom") {
id = id.replaceAll(" ", "-");
}
return id;
},
patchData() {
const data = {};
if (this.licenseId !== this.project.license.id) {
data.license_id = this.licenseId;
data.license_url = this.licenseUrl ? this.licenseUrl : null;
} else if (this.licenseUrl !== this.project.license.url) {
data.license_url = this.licenseUrl ? this.licenseUrl : null;
}
return data;
},
hasChanges() {
return Object.keys(this.patchData).length > 0;
},
},
methods: {
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData);
}
},
},
const licenseUrl = ref(props.project.license.url);
const license: Ref<{
friendly: string;
short: string;
requiresOnlyOrLater?: boolean;
}> = ref({
friendly: "",
short: "",
requiresOnlyOrLater: false,
});
const allowOrLater = ref(props.project.license.id.includes("-or-later"));
const nonSpdxLicense = ref(props.project.license.id.includes("LicenseRef-"));
const oldLicenseId = props.project.license.id;
const trimmedLicenseId = oldLicenseId
.replaceAll("-only", "")
.replaceAll("-or-later", "")
.replaceAll("LicenseRef-", "");
license.value = builtinLicenses.find((x) => x.short === trimmedLicenseId) ?? {
friendly: "Custom",
short: oldLicenseId.replaceAll("LicenseRef-", ""),
requiresOnlyOrLater: oldLicenseId.includes("-or-later"),
};
if (oldLicenseId === "LicenseRef-Unknown") {
// Mark it as not having a license, forcing the user to select one
license.value = {
friendly: "",
short: oldLicenseId.replaceAll("LicenseRef-", ""),
requiresOnlyOrLater: false,
};
}
const hasPermission = computed(() => {
return (props.currentMember?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS;
});
const licenseId = computed(() => {
let id = "";
if (
(nonSpdxLicense && license.value.friendly === "Custom") ||
license.value.short === "All-Rights-Reserved" ||
license.value.short === "Unknown"
) {
id += "LicenseRef-";
}
id += license.value.short;
if (license.value.requiresOnlyOrLater) {
id += allowOrLater.value ? "-or-later" : "-only";
}
if (nonSpdxLicense && license.value.friendly === "Custom") {
id = id.replaceAll(" ", "-");
}
return id;
});
const patchRequestPayload = computed(() => {
const payload: {
license_id?: string;
license_url?: string | null; // null = remove url
} = {};
if (licenseId.value !== props.project.license.id) {
payload.license_id = licenseId.value;
}
if (licenseUrl.value !== props.project.license.url) {
payload.license_url = licenseUrl.value ? licenseUrl.value : null;
}
return payload;
});
const hasChanges = computed(() => {
return Object.keys(patchRequestPayload.value).length > 0;
});
function saveChanges() {
props.patchProject(patchRequestPayload.value);
}
</script>

View File

@@ -96,7 +96,7 @@
<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-red p-4">
<PanelErrorIcon class="size-12 text-red" />
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
</div>
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
</div>
@@ -343,7 +343,7 @@
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
@@ -352,10 +352,16 @@
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
We're preparing your server, this may take a few minutes.
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
</div>
<NuxtPage
@@ -392,10 +398,9 @@ import {
import DOMPurify from "dompurify";
import { ButtonStyled } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import { reloadNuxtApp } from "#app";
import { reloadNuxtApp, navigateTo } from "#app";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
import { usePyroConsole } from "~/store/console.ts";
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
const socket = ref<WebSocket | null>(null);
const isReconnecting = ref(false);
@@ -662,22 +667,49 @@ const newMCVersion = ref<string | null>(null);
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
switch (data.result) {
case "ok":
case "ok": {
if (!serverData.value) break;
serverData.value.status = "available";
if (!isFirstMount.value) {
await server.refresh();
}
if (server.general) {
if (newLoader.value) server.general.loader = newLoader.value;
if (newLoaderVersion.value) server.general.loader_version = newLoaderVersion.value;
if (newMCVersion.value) server.general.mc_version = newMCVersion.value;
stopPolling();
try {
await new Promise((resolve) => setTimeout(resolve, 2000));
let attempts = 0;
const maxAttempts = 3;
let hasValidData = false;
while (!hasValidData && attempts < maxAttempts) {
attempts++;
await server.refresh(["general"], {
preserveConnection: true,
preserveInstallState: true,
});
if (serverData.value?.loader && serverData.value?.mc_version) {
hasValidData = true;
serverData.value.status = "available";
await server.refresh(["content", "startup"]);
break;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!hasValidData) {
console.error("Failed to get valid server data after installation");
}
} catch (err: unknown) {
console.error("Error refreshing data after installation:", err);
}
newLoader.value = null;
newLoaderVersion.value = null;
newMCVersion.value = null;
error.value = null;
break;
}
case "err": {
console.log("failed to install");
console.log(data);
@@ -708,20 +740,9 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
const onReinstall = (potentialArgs: any) => {
if (!serverData.value) return;
serverData.value.status = "installing";
// serverData.value.loader = potentialArgs.loader;
// serverData.value.loader_version = potentialArgs.lVersion;
// serverData.value.mc_version = potentialArgs.mVersion;
// if (potentialArgs?.loader) {
// console.log("setting loadeconsole
// serverData.value.loader = potentialArgs.loader;
// }
// if (potentialArgs?.lVersion) {
// serverData.value.loader_version = potentialArgs.lVersion;
// }
// if (potentialArgs?.mVersion) {
// serverData.value.mc_version = potentialArgs.mVersion;
// }
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader;
}
@@ -732,15 +753,9 @@ const onReinstall = (potentialArgs: any) => {
newMCVersion.value = potentialArgs.mVersion;
}
if (!isFirstMount.value) {
server.refresh();
}
error.value = null;
errorTitle.value = "Error";
errorMessage.value = "An unexpected error occurred.";
console.log(serverData.value);
};
const updateStats = (currentStats: Stats["current"]) => {
@@ -762,7 +777,6 @@ const updatePowerState = (
state: ServerState,
details?: { oom_killed?: boolean; exit_code?: number },
) => {
console.log("Power state:", state, details);
serverPowerState.value = state;
if (state === "crashed") {
@@ -959,17 +973,15 @@ onUnmounted(() => {
watch(
() => serverData.value?.status,
(newStatus) => {
(newStatus, oldStatus) => {
if (isFirstMount.value) {
isFirstMount.value = false;
return;
}
if (newStatus === "installing") {
if (newStatus === "installing" && oldStatus !== "installing") {
countdown.value = 15;
startPolling();
} else {
stopPolling();
server.refresh();
}
},
);
@@ -996,7 +1008,16 @@ definePageMeta({
}
.mobile-blurred-servericon::before {
@apply absolute left-0 top-0 block h-36 w-full bg-cover bg-center bg-no-repeat blur-2xl sm:hidden;
position: absolute;
left: 0;
top: 0;
display: block;
height: 9rem;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(1rem);
content: "";
background-image: linear-gradient(
to bottom,
@@ -1005,4 +1026,10 @@ definePageMeta({
),
var(--server-bg-image);
}
@media screen and (min-width: 640px) {
.mobile-blurred-servericon::before {
display: none;
}
}
</style>

View File

@@ -53,7 +53,10 @@
</div>
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
<ButtonStyled type="standard">
<button @click="showbackupSettingsModal">
<button
:disabled="server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
@@ -63,13 +66,16 @@
v-tooltip="
isServerRunning && !userPreferences.backupWhileRunning
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
: ''
: server.general?.status === 'installing'
? 'Cannot create backups while server is being installed'
: ''
"
class="w-full sm:w-fit"
:disabled="
(isServerRunning && !userPreferences.backupWhileRunning) ||
data.used_backup_quota >= data.backup_quota ||
backups.some((backup) => backup.ongoing)
backups.some((backup) => backup.ongoing) ||
server.general?.status === 'installing'
"
@click="showCreateModel"
>
@@ -90,108 +96,111 @@
automatically refresh when the backup is complete.
</div>
<li
v-for="(backup, index) in backups"
:key="backup.id"
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-4 shadow-md"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-row items-center gap-4">
<div
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
>
<UiServersIconsLoadingIcon
v-if="backup.ongoing"
v-tooltip="'Backup in progress'"
class="size-6 animate-spin"
/>
<LockIcon v-else-if="backup.locked" class="size-8" />
<BoxIcon v-else class="size-8" />
</div>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<div class="max-w-full truncate text-xl font-bold text-contrast">
{{ backup.name }}
</div>
<div class="flex w-full flex-col gap-2">
<li
v-for="(backup, index) in backups"
:key="backup.id"
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-2 shadow-md"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-row items-center gap-4">
<div
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
>
<UiServersIconsLoadingIcon
v-if="backup.ongoing"
v-tooltip="'Backup in progress'"
class="size-6 animate-spin"
/>
<LockIcon v-else-if="backup.locked" class="size-8" />
<BoxIcon v-else class="size-8" />
</div>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<div class="max-w-full truncate font-bold text-contrast">
{{ backup.name }}
</div>
<div
v-if="index == 0"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="size-4" /> Latest
<div
v-if="index == 0"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="size-4" /> Latest
</div>
</div>
<div class="flex items-center gap-1 text-xs">
<CalendarIcon class="size-4" />
{{
new Date(backup.created_at).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}}
</div>
</div>
<div class="flex items-center gap-2 text-sm font-semibold">
<CalendarIcon class="size-4" />
{{
new Date(backup.created_at).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}}
</div>
</div>
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
<UiServersTeleportOverflowMenu
direction="left"
position="bottom"
class="bg-transparent"
:disabled="backups.some((b) => b.ongoing)"
:options="[
{
id: 'rename',
action: () => {
renameBackupName = backup.name;
currentBackup = backup.id;
renameBackupModal?.show();
},
},
{
id: 'restore',
action: () => {
currentBackup = backup.id;
restoreBackupModal?.show();
},
},
{ id: 'download', action: () => initiateDownload(backup.id) },
{
id: 'lock',
action: () => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
},
},
{
id: 'delete',
action: () => {
currentBackup = backup.id;
deleteBackupModal?.show();
},
color: 'red',
},
]"
>
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #restore> <ClipboardCopyIcon /> Restore </template>
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
<template v-else #lock> <LockIcon /> Lock </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
<UiServersTeleportOverflowMenu
direction="left"
position="bottom"
class="bg-transparent"
:options="[
{
id: 'rename',
action: () => {
renameBackupName = backup.name;
currentBackup = backup.id;
renameBackupModal?.show();
},
},
{
id: 'restore',
action: () => {
currentBackup = backup.id;
restoreBackupModal?.show();
},
},
{ id: 'download', action: () => initiateDownload(backup.id) },
{
id: 'lock',
action: () => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
},
},
{
id: 'delete',
action: () => {
currentBackup = backup.id;
deleteBackupModal?.show();
},
color: 'red',
},
]"
>
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #restore> <ClipboardCopyIcon /> Restore </template>
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
<template v-else #lock> <LockIcon /> Lock </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</li>
</li>
</div>
</ul>
<div

View File

@@ -34,13 +34,18 @@
<UiServersFilesBrowseNavbar
:breadcrumb-segments="breadcrumbSegments"
:search-query="searchQuery"
:sort-method="sortMethod"
:current-filter="viewFilter"
@navigate="navigateToSegment"
@sort="sortFiles"
@create="showCreateModal"
@upload="initiateFileUpload"
@filter="handleFilter"
@update:search-query="searchQuery = $event"
/>
<UiServersFilesLabelBar
:sort-field="sortMethod"
:sort-desc="sortDesc"
@sort="handleSort"
/>
<FilesUploadDropdown
v-if="props.server.fs"
ref="uploadDropdownRef"
@@ -94,7 +99,6 @@
</div>
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
<UiServersFilesLabelBar />
<UiServersFileVirtualList
:items="filteredItems"
@delete="showDeleteModal"
@@ -196,7 +200,8 @@ const operationHistory = ref<Operation[]>([]);
const redoStack = ref<Operation[]>([]);
const searchQuery = ref("");
const sortMethod = ref("default");
const sortMethod = ref("name");
const sortDesc = ref(false);
const maxResults = 100;
const currentPage = ref(1);
@@ -227,6 +232,14 @@ const uploadDropdownRef = ref();
const data = computed(() => props.server.general);
const viewFilter = ref("all");
const handleFilter = (type: string) => {
viewFilter.value = type;
sortMethod.value = "name";
sortDesc.value = false;
};
useHead({
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
});
@@ -567,6 +580,51 @@ const applyDefaultSort = (items: DirectoryItem[]) => {
});
};
const handleSort = (field: string) => {
if (sortMethod.value === field) {
sortDesc.value = !sortDesc.value;
} else {
sortMethod.value = field;
sortDesc.value = false;
}
};
const applySort = (items: DirectoryItem[]) => {
let result = [...items];
switch (viewFilter.value) {
case "filesOnly":
result = result.filter((item) => item.type !== "directory");
break;
case "foldersOnly":
result = result.filter((item) => item.type === "directory");
break;
}
const compareItems = (a: DirectoryItem, b: DirectoryItem) => {
if (viewFilter.value === "all") {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
}
switch (sortMethod.value) {
case "modified":
return sortDesc.value
? new Date(a.modified).getTime() - new Date(b.modified).getTime()
: new Date(b.modified).getTime() - new Date(a.modified).getTime();
case "created":
return sortDesc.value
? new Date(a.created).getTime() - new Date(b.created).getTime()
: new Date(b.created).getTime() - new Date(a.created).getTime();
default:
return sortDesc.value ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name);
}
};
result.sort(compareItems);
return result;
};
const filteredItems = computed(() => {
let result = [...items.value];
@@ -575,24 +633,7 @@ const filteredItems = computed(() => {
result = result.filter((item) => item.name.toLowerCase().includes(query));
}
switch (sortMethod.value) {
case "modified":
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
break;
case "created":
result.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
break;
case "filesOnly":
result = result.filter((item) => item.type !== "directory");
break;
case "foldersOnly":
result = result.filter((item) => item.type === "directory");
break;
default:
result = applyDefaultSort(result);
}
return result;
return applySort(result);
});
const { reset } = useInfiniteScroll(
@@ -656,10 +697,6 @@ const onAnywhereClicked = (e: MouseEvent) => {
}
};
const sortFiles = (method: string) => {
sortMethod.value = method;
};
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"];
const editFile = async (item: { name: string; type: string; path: string }) => {
@@ -717,7 +754,9 @@ watch(
async (newQuery) => {
currentPage.value = 1;
searchQuery.value = "";
sortMethod.value = "default";
viewFilter.value = "all";
sortMethod.value = "name";
sortDesc.value = false;
currentPath.value = Array.isArray(newQuery.path)
? newQuery.path.join("")

View File

@@ -80,14 +80,19 @@
<div class="flex flex-col-reverse gap-6 md:flex-col">
<UiServersServerStats :data="stats" />
<div
class="relative flex h-[600px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
<UiServersPanelServerStatus :state="serverPowerState" />
</div>
</div>
<!-- <div class="flex flex-row items-center gap-2 text-sm font-medium">
<InfoIcon class="hidden sm:block" />
Click and drag to select lines, then CMD+C to copy
</div> -->
<UiServersPanelTerminal :full-screen="fullScreen">
<div class="relative w-full px-4 pt-4">
<ul
@@ -164,7 +169,7 @@
</div>
</div>
</div>
<UiServersPanelOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
<h2>Could not connect to the server.</h2>
<p>

View File

@@ -116,7 +116,7 @@
</div>
</div>
</div>
<UiServersPyroLoading v-else />
<div v-else />
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"

View File

@@ -27,7 +27,7 @@
{{ data?.sftp_host }}
</span>
<span class="text-xs uppercase text-secondary">server address</span>
<span class="text-xs text-secondary">Server Address</span>
</div>
<ButtonStyled type="transparent">
@@ -41,9 +41,9 @@
</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 p-4"
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
>
<div class="flex items-center justify-between">
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{ data?.sftp_username }}
</span>
@@ -57,12 +57,12 @@
</button>
</ButtonStyled>
</div>
<span class="text-xs uppercase text-secondary">username</span>
<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 items-center justify-between">
<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)
@@ -89,7 +89,7 @@
</ButtonStyled>
</div>
</div>
<span class="text-xs uppercase text-secondary">password</span>
<span class="text-xs text-secondary">Password</span>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@
</form>
</NewModal>
<NewModal ref="editAllocationModal" header="Edit Allocation">
<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
@@ -40,7 +40,7 @@
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<SaveIcon /> Update Allocation
<SaveIcon /> Update allocation
</button>
</ButtonStyled>
<ButtonStyled>
@@ -94,7 +94,7 @@
/>
<div
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow p-4"
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"
@@ -108,7 +108,7 @@
>
{{ record.type }}
</span>
<span class="text-xs uppercase text-secondary">type</span>
<span class="text-xs text-secondary">Type</span>
</div>
</td>
<td class="w-2/6 py-3 md:w-1/3">
@@ -118,7 +118,7 @@
>
{{ record.name }}
</span>
<span class="text-xs uppercase text-secondary">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">
@@ -128,7 +128,7 @@
>
{{ record.content }}
</span>
<span class="text-xs uppercase text-secondary">content</span>
<span class="text-xs text-secondary">Content</span>
</div>
</td>
</tr>
@@ -190,7 +190,7 @@
<span class="text-md font-bold tracking-wide text-contrast">
{{ allocation.name }}
</span>
<span class="hidden text-xs uppercase text-secondary sm:block">name</span>
<span class="hidden text-xs text-secondary sm:block">Name</span>
</div>
<div class="flex flex-col gap-1">
<span
@@ -198,7 +198,7 @@
>
{{ allocation.port }}
</span>
<span class="hidden text-xs uppercase text-secondary sm:block">port</span>
<span class="hidden text-xs text-secondary sm:block">Port</span>
</div>
</div>
</div>

View File

@@ -59,6 +59,11 @@ const preferences = {
"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.",
@@ -84,6 +89,7 @@ type UserPreferences = {
const defaultPreferences: UserPreferences = {
ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,

View File

@@ -17,7 +17,7 @@
>
Minecraft Wiki
</NuxtLink>
has more detailed information about each property.
has more detailed information.
</div>
</div>
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
@@ -134,10 +134,29 @@ const isUpdating = ref(false);
const searchInput = ref("");
const data = computed(() => props.server.general);
const { data: propsData, status } = await useAsyncData(
"ServerProperties",
async () => await props.server.general?.fetchConfigFile("ServerProperties"),
);
const { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
const rawProps = await props.server.fs?.downloadFile("server.properties");
if (!rawProps) return null;
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("=");
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;
}
return properties;
});
const liveProperties = ref<Record<string, any>>({});
const originalProperties = ref<Record<string, any>>({});

View File

@@ -35,10 +35,9 @@
<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. Your server is running Minecraft
{{ data.mc_version }}. By default, only the Java versions compatible with this
version of Minecraft are shown. Some mods or modpacks may require a specific Java
version.
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">

View File

@@ -4,44 +4,91 @@
class="experimental-styles-within relative mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<div
v-if="serverList.length > 0 || isPollingForNewServers"
class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row"
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"
>
<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 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>
<UiCopyCode
:text="(fetchError as PyroFetchError).message || 'Unknown error'"
:copyable="false"
:selectable="false"
:language="'json'"
/>
</li>
</ul>
</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 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-if="serverList.length === 0 && !isPollingForNewServers"
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>
<ul v-if="filteredData.length > 0" class="m-0 flex flex-col gap-4 p-0">
<UiServersServerListing
v-for="server in filteredData"
@@ -68,10 +115,12 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import Fuse from "fuse.js";
import { PlusIcon, SearchIcon } from "@modrinth/assets";
import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { reloadNuxtApp } from "#app";
import type { PyroFetchError } from "~/composables/pyroFetch";
import type { Server } from "~/types/servers";
definePageMeta({
@@ -87,21 +136,23 @@ interface ServerResponse {
}
const route = useRoute();
const hasError = ref(false);
const isPollingForNewServers = ref(false);
const { data: serverResponse, refresh } = await useAsyncData<ServerResponse>(
"ServerList",
async () => {
try {
const response = await usePyroFetch<{ servers: Server[] }>("servers");
return response;
} catch {
throw new PyroFetchError("Unable to load servers");
}
},
);
const {
data: serverResponse,
error: fetchError,
refresh,
} = await useAsyncData<ServerResponse>("ServerList", () => usePyroFetch<ServerResponse>("servers"));
const serverList = computed(() => serverResponse.value?.servers || []);
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response;
});
const serverList = computed(() => {
if (!serverResponse.value) return [];
return serverResponse.value.servers;
});
const searchInput = ref("");