You've already forked pages
forked from didirus/AstralRinth
Merge commit 'daf699911104207a477751916b36a371ee8f7e38' into feature-clean
This commit is contained in:
BIN
.github/assets/api_cover.png
vendored
Normal file
BIN
.github/assets/api_cover.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
1445
Cargo.lock
generated
1445
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -66,10 +66,10 @@
|
||||
|
||||
if (x.author) {
|
||||
item.creator = {
|
||||
name: x.author,
|
||||
type: 'user',
|
||||
id: x.author,
|
||||
link: 'https://modrinth.com/user/' + x.author,
|
||||
name: x.author.name,
|
||||
type: x.author.type,
|
||||
id: x.author.slug,
|
||||
link: `https://modrinth.com/${x.author.type}/${x.author.slug}`,
|
||||
linkProps: { target: '_blank' },
|
||||
}
|
||||
}
|
||||
@@ -329,6 +329,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
type ProjectListEntryAuthor = {
|
||||
name: string
|
||||
slug: string
|
||||
type: 'user' | 'organization'
|
||||
}
|
||||
|
||||
type ProjectListEntry = {
|
||||
path: string
|
||||
name: string
|
||||
slug?: string
|
||||
author: ProjectListEntryAuthor | null
|
||||
version: string | null
|
||||
file_name: string
|
||||
icon: string | null
|
||||
disabled: boolean
|
||||
updateVersion?: string
|
||||
outdated: boolean
|
||||
updated: dayjs.Dayjs
|
||||
project_type: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const isPackLocked = computed(() => {
|
||||
return props.instance.linked_data && props.instance.linked_data.locked
|
||||
})
|
||||
@@ -338,7 +360,7 @@ const canUpdatePack = computed(() => {
|
||||
})
|
||||
const exportModal = ref(null)
|
||||
|
||||
const projects = ref([])
|
||||
const projects = ref<ProjectListEntry[]>([])
|
||||
const selectedFiles = ref([])
|
||||
const selectedProjects = computed(() =>
|
||||
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
|
||||
@@ -347,7 +369,7 @@ const selectedProjects = computed(() =>
|
||||
const selectionMap = ref(new Map())
|
||||
|
||||
const initProjects = async (cacheBehaviour?) => {
|
||||
const newProjects = []
|
||||
const newProjects: ProjectListEntry[] = []
|
||||
|
||||
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
|
||||
const fetchProjects = []
|
||||
@@ -384,21 +406,29 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
|
||||
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
|
||||
|
||||
let owner
|
||||
|
||||
let author: ProjectListEntryAuthor | null
|
||||
if (org) {
|
||||
owner = org.name
|
||||
author = {
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
type: 'organization',
|
||||
}
|
||||
} else if (team) {
|
||||
owner = team.find((x) => x.is_owner).user.username
|
||||
const teamMember = team.find((x) => x.is_owner)
|
||||
author = {
|
||||
name: teamMember.user.username,
|
||||
slug: teamMember.user.username,
|
||||
type: 'user',
|
||||
}
|
||||
} else {
|
||||
owner = null
|
||||
author = null
|
||||
}
|
||||
|
||||
newProjects.push({
|
||||
path,
|
||||
name: project.title,
|
||||
slug: project.slug,
|
||||
author: owner,
|
||||
author,
|
||||
version: version.version_number,
|
||||
file_name: file.file_name,
|
||||
icon: project.icon_url,
|
||||
@@ -417,7 +447,7 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
newProjects.push({
|
||||
path,
|
||||
name: file.file_name.replace('.disabled', ''),
|
||||
author: '',
|
||||
author: null,
|
||||
version: null,
|
||||
file_name: file.file_name,
|
||||
icon: null,
|
||||
|
||||
@@ -7,18 +7,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
theseus = { path = "../../packages/app-lib", features = ["cli"] }
|
||||
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
url = "2.2"
|
||||
webbrowser = "0.8.13"
|
||||
dunce = "1.0.3"
|
||||
|
||||
futures = "0.3"
|
||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = "0.3.18"
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
@@ -28,12 +28,9 @@ tauri-plugin-single-instance = { version = "2.2.0" }
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
futures = "0.3"
|
||||
daedalus = { path = "../../packages/daedalus" }
|
||||
chrono = "0.4.26"
|
||||
|
||||
dirs = "5.0.1"
|
||||
|
||||
url = "2.2"
|
||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
os_info = "3.7.0"
|
||||
@@ -41,9 +38,6 @@ os_info = "3.7.0"
|
||||
tracing = "0.1.37"
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
lazy_static = "1"
|
||||
once_cell = "1"
|
||||
|
||||
dashmap = "6.0.1"
|
||||
paste = "1.0.15"
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ reqwest = { version = "0.12.5", default-features = false, features = [
|
||||
"rustls-tls-native-roots",
|
||||
] }
|
||||
async_zip = { version = "0.0.17", features = ["full"] }
|
||||
semver = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
bytes = "1.6.0"
|
||||
rust-s3 = { version = "0.33.0", default-features = false, features = [
|
||||
@@ -39,4 +38,3 @@ tracing-error = "0.2.0"
|
||||
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-futures = { version = "0.2.5", features = ["futures", "tokio"] }
|
||||
|
||||
@@ -598,7 +598,7 @@ async fn fetch(
|
||||
))
|
||||
})?;
|
||||
|
||||
let file_name = value.split('/').last()
|
||||
let file_name = value.split('/').next_back()
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"Unable reading filename for data key {key} at path {value}",
|
||||
|
||||
@@ -44,6 +44,10 @@ export default defineConfig({
|
||||
label: 'Contributing to Modrinth',
|
||||
autogenerate: { directory: 'contributing' },
|
||||
},
|
||||
{
|
||||
label: 'Guides',
|
||||
autogenerate: { directory: 'guide' },
|
||||
},
|
||||
// Add the generated sidebar group to the sidebar.
|
||||
...openAPISidebarGroups,
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: '3.0.0'
|
||||
|
||||
info:
|
||||
version: v2.7.0/15cf3fc
|
||||
version: v2.7.0/366f528
|
||||
title: Labrinth
|
||||
termsOfService: https://modrinth.com/legal/terms
|
||||
contact:
|
||||
@@ -51,35 +51,7 @@ info:
|
||||
Please note that certain scopes and requests cannot be completed with a personal access token or using OAuth.
|
||||
For example, deleting a user account can only be done through Modrinth's frontend.
|
||||
|
||||
### OAuth2
|
||||
Applications interacting with an authenticated API should create an OAuth2 application.
|
||||
You can do this in [the developer settings](https://modrinth.com/settings/applications).
|
||||
|
||||
Make sure to save your application secret, as you will not be able to access it after you leave the page.
|
||||
|
||||
Once you have created a client, use the following URL to have a user authorize your client:
|
||||
```
|
||||
https://modrinth.com/auth/authorize?client_id=<CLIENT_ID>&redirect_uri=<CALLBACK_URL>&scope=<SCOPE_ONE>+<SCOPE_TWO>+<SCOPE_THREE>
|
||||
```
|
||||
> You can get a list of all scope names [here](https://github.com/modrinth/code/tree/main/apps/labrinth/src/models/v3/pats.rs).
|
||||
|
||||
Then, send a `POST` request to the following URL to get the token:
|
||||
|
||||
```
|
||||
https://api.modrinth.com/_internal/oauth/token
|
||||
```
|
||||
|
||||
> Note that you will need to provide your application's secret under the Authorization header.
|
||||
|
||||
In the body of your request, make sure to include the following:
|
||||
- `code`: The code generated when authorizing your client
|
||||
- `client_id`: Your client ID (found in developer settings)
|
||||
- `redirect_uri`: A valid redirect URI provided in your application's settings
|
||||
- `grant_type`: This will need to be `authorization_code`.
|
||||
|
||||
If your token request fails for any reason, you will need to get another code from the authorization process.
|
||||
|
||||
This route will be changed in the future to move the `_internal` part to `v3`.
|
||||
A detailed guide on OAuth has been published in [Modrinth's technical documentation](https://docs.modrinth.com/guide/oauth).
|
||||
|
||||
### Personal access tokens
|
||||
Personal access tokens (PATs) can be generated in from [the user settings](https://modrinth.com/settings/account).
|
||||
@@ -3018,6 +2990,24 @@ paths:
|
||||
$ref: '#/components/schemas/InvalidInputError'
|
||||
'404':
|
||||
description: The requested item(s) were not found or no authorization to access the requested item(s)
|
||||
delete:
|
||||
summary: Remove user's avatar
|
||||
operationId: deleteUserIcon
|
||||
tags:
|
||||
- users
|
||||
security:
|
||||
- TokenAuth: ['USER_WRITE']
|
||||
responses:
|
||||
'204':
|
||||
description: Expected response to a valid request
|
||||
'400':
|
||||
description: Request was invalid, see given error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InvalidInputError'
|
||||
'404':
|
||||
description: The requested item(s) were not found or no authorization to access the requested item(s)
|
||||
/user/{id|username}/projects:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserIdentifier'
|
||||
|
||||
95
apps/docs/src/content/docs/guide/oauth.md
Normal file
95
apps/docs/src/content/docs/guide/oauth.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: The hitchhiker's guide to OAuth
|
||||
description: Guide for using Modrinth OAuth to interact with the API on users' behalf.
|
||||
---
|
||||
|
||||
Modrinth allows developers to create applications which, once authorized by a Modrinth user, let the developer interact with the API on their behalf. The flow used to get an API token is based on the OAuth 2 protocol. It is recommended that most people use an existing OAuth library to handle the authentication. If you want to implement it from scratch, you will need to look into [RFC 6749]. If the only user of the application is yourself, a personal access token (PAT) may be a better fit.
|
||||
|
||||
If you're familiar with OAuth 2, these are the URLs you will need:
|
||||
|
||||
| Name | URL |
|
||||
|--------------------|--------------------------------------------------|
|
||||
| Authorization page | `https://modrinth.com/auth/authorize` |
|
||||
| Token exchange | `https://api.modrinth.com/_internal/oauth/token` |
|
||||
|
||||
The flow will generally look like this:
|
||||
|
||||
1. User is redirected to Modrinth to authorize your application
|
||||
2. User is redirected back to your site after authorizing, with an authorization code
|
||||
3. Your backend exchanges this code for an access token
|
||||
|
||||
## Register your application
|
||||
|
||||
To start off, you need to [register an application] in Modrinth's systems. The settings chosen here can always be changed later. You need to select what permissions you need, called scopes. For security reasons you will want to select only the scopes you need. See the [principle of least privilege].
|
||||
|
||||
In addition to name and scopes, you will also need to add one or more redirect URIs. These are the URIs that the user can be redirected to after they authorize your application.
|
||||
|
||||
After you've registered your application, it is important that you take note of the client secret somewhere safe. If the client secret is to ever leak, it is important that you regenerate it to ensure the security of your authorized users. If your client secret or access tokens are found exposed in the wild, your application may be disabled without prior notice.
|
||||
|
||||
## Getting authorization
|
||||
|
||||
Once the user is ready to authorize your application, you need to construct a URL to redirect them to. The authorization URL for Modrinth is `https://api.modrinth.com/_internal/oauth/token`. Supply the following query parameters:
|
||||
|
||||
| Query parameter | Description |
|
||||
|-----------------|-------------------------------------------------------------------------------------------|
|
||||
| `response_type` | In Modrinth this always needs to be `code`, since only code grants are supported |
|
||||
| `client_id` | The application identifier found in the settings |
|
||||
| `scope` | The permissions you need access to |
|
||||
| `state` | A mechanism to prevent certain attacks. Explained further below. Recommended but optional |
|
||||
| `redirect_uri` | The URI the user is redirect to after finishing authorization |
|
||||
|
||||
You might have noticed the `state` parameter. [CSRF] (Cross-site request forgery), and [clickjacking] are security vulnerabilities that you're recommended to protect against. In OAuth2 this is usually done with the `state` parameter. When the user initiates a request to start authorization, you include a `state` which is unique to this request. This can, for example, be saved in localStorge or a cookie. When the redirect URI is called, you verify that the `state` parameter is the same. Using `state` is optional, but recommended.
|
||||
|
||||
The scope identifiers are currently best found in the backend source code located at [`apps/labrinth/src/models/v3/pats.rs`]. The scope parameter is an array of scope identifiers, seperated by a plus sign (`+`).
|
||||
|
||||
The redirect URI is the endpoint on your server that will receive the code which can eventually be used to act on the user's behalf. For security reasons the redirect URI used has to be allowlisted in your application settings. The redirect will contain the following query parameters:
|
||||
|
||||
| Query parameter | Description |
|
||||
|-----------------|----------------------------------------------------|
|
||||
| `code` | The code that can be exchanged for an access token |
|
||||
| `client_id` | Your client id |
|
||||
| `redirect_uri` | The redirect URI which was used |
|
||||
| `grant_type` | Always `authorization_code` in Modrinth |
|
||||
|
||||
## Exchanging tokens
|
||||
|
||||
If you've followed the previous section on getting authorization, you should now have an authorization code. Before you can access the API, you need to exchange this code for an access token. This is done by sending a POST request to the exchange token endpoint, `https://api.modrinth.com/_internal/oauth/token`. This request has to be of type urlencoded form. Make sure the `Content-Type` header is set to `application/x-www-form-urlencoded`. To authenticate this request you need to place your client secret in the `Authorization` header.
|
||||
|
||||
In the body use these fields:
|
||||
|
||||
| Field | Description |
|
||||
|----------------|--------------------------------------------------------------|
|
||||
| `code` | The authorization code |
|
||||
| `client_id` | Your client id, the same as in the authorization request |
|
||||
| `redirect_uri` | The redirect URI which was redirected to after authorization |
|
||||
| `grant_type` | Always `authorization_code` in Modrinth |
|
||||
|
||||
If the request succeeds, you should receive a JSON payload with these fields:
|
||||
|
||||
| Field | Description |
|
||||
|----------------|------------------------------------------------------|
|
||||
| `access_token` | The access token you can use to access the API |
|
||||
| `token_type` | Currently only `Bearer` |
|
||||
| `expires_in` | The amount of seconds until the access token expires |
|
||||
|
||||
To use this access token, you attach it to API requests in the `Authorization` header. To get basic information about the authorizer, you can use the [`/user` endpoint], which automatically gets the user from the header.
|
||||
|
||||
If you have any questions, you're welcome to ask in #api-development in the [Discord guild], or create a ticket on the [support portal].
|
||||
|
||||
[RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
|
||||
|
||||
[register an application]: https://modrinth.com/settings/applications
|
||||
|
||||
[principle of least privilege]: https://en.wikipedia.org/wiki/Principle_of_least_privilege
|
||||
|
||||
[`apps/labrinth/src/models/v3/pats.rs`]: https://github.com/modrinth/code/blob/main/apps/labrinth/src/models/v3/pats.rs
|
||||
|
||||
[CSRF]: https://en.wikipedia.org/wiki/Cross-site_request_forgery
|
||||
|
||||
[Clickjacking]: https://en.wikipedia.org/wiki/Clickjacking
|
||||
|
||||
[`/user` endpoint]: https://docs.modrinth.com/api/operations/getuserfromauth/
|
||||
|
||||
[Discord guild]: https://discord.modrinth.com
|
||||
|
||||
[support portal]: https://support.modrinth.com/en/
|
||||
@@ -183,7 +183,7 @@ async function createProject() {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@ async function performAction(notification, actionIndex) {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ async function createOrganization() {
|
||||
addNotification({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatProjectType } from "~/plugins/shorthands.js";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CheckIcon,
|
||||
@@ -117,7 +116,9 @@ import {
|
||||
LightBulbIcon,
|
||||
SendIcon,
|
||||
ScaleIcon,
|
||||
DropdownIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatProjectType } from "~/plugins/shorthands.js";
|
||||
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Creating backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<label for="backup-name-input">
|
||||
<span class="text-lg font-semibold text-contrast"> Name </span>
|
||||
</label>
|
||||
<input
|
||||
id="backup-name-input"
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. Before 1.21"
|
||||
maxlength="64"
|
||||
:placeholder="`Backup #${newBackupAmount}`"
|
||||
maxlength="48"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
If left empty, the backup name will default to
|
||||
<span class="font-semibold"> Backup #{{ newBackupAmount }}</span>
|
||||
<div v-if="nameExists && !isCreating" class="flex items-center gap-1">
|
||||
<IssuesIcon class="hidden text-orange sm:block" />
|
||||
<span class="text-sm text-orange">
|
||||
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
|
||||
>'
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isRateLimited" class="mt-2 text-sm text-red">
|
||||
You're creating backups too fast. Please wait a moment before trying again.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<div class="mt-2 flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isCreating" @click="createBackup">
|
||||
<button :disabled="isCreating || nameExists" @click="createBackup">
|
||||
<PlusIcon />
|
||||
Create backup
|
||||
</button>
|
||||
@@ -41,24 +44,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, InfoIcon } from "@modrinth/assets";
|
||||
import { IssuesIcon, PlusIcon, XIcon } from "@modrinth/assets";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupCreated"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const isCreating = ref(false);
|
||||
const isRateLimited = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
const backupName = ref("");
|
||||
const newBackupAmount = computed(() =>
|
||||
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
|
||||
);
|
||||
|
||||
const trimmedName = computed(() => backupName.value.trim());
|
||||
|
||||
const nameExists = computed(() => {
|
||||
if (!props.server.backups?.data) return false;
|
||||
return props.server.backups.data.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
);
|
||||
});
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
@@ -67,30 +76,38 @@ const focusInput = () => {
|
||||
});
|
||||
};
|
||||
|
||||
function show() {
|
||||
backupName.value = "";
|
||||
isCreating.value = false;
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
backupName.value = "";
|
||||
};
|
||||
|
||||
const createBackup = async () => {
|
||||
if (!backupName.value.trim()) {
|
||||
if (backupName.value.trim().length === 0) {
|
||||
backupName.value = `Backup #${newBackupAmount.value}`;
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
isRateLimited.value = false;
|
||||
try {
|
||||
await props.server.backups?.create(backupName.value);
|
||||
await props.server.refresh();
|
||||
await props.server.backups?.create(trimmedName.value);
|
||||
hideModal();
|
||||
emit("backupCreated", { success: true, message: "Backup created successfully" });
|
||||
await props.server.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||
isRateLimited.value = true;
|
||||
backupError.value = "You're creating backups too fast.";
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error creating backup",
|
||||
text: "You're creating backups too fast.",
|
||||
});
|
||||
} else {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupCreated", { success: false, message: backupError.value });
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
addNotification({ type: "error", title: "Error creating backup", text: message });
|
||||
}
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
@@ -98,7 +115,7 @@ const createBackup = async () => {
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
show,
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,86 +1,45 @@
|
||||
<template>
|
||||
<NewModal ref="modal" danger header="Deleting backup">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-[#0e0e0ea4] p-6">
|
||||
<div class="text-2xl font-extrabold text-contrast">
|
||||
{{ backupName }}
|
||||
</div>
|
||||
<div class="flex gap-2 font-semibold text-contrast">
|
||||
<CalendarIcon />
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-4">
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isDeleting" @click="deleteBackup">
|
||||
<TrashIcon />
|
||||
Delete backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="hideModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
danger
|
||||
title="Are you sure you want to delete this backup?"
|
||||
proceed-label="Delete backup"
|
||||
:confirmation-text="currentBackup?.name ?? 'null'"
|
||||
has-to-type
|
||||
@proceed="emit('delete', currentBackup)"
|
||||
>
|
||||
<BackupItem
|
||||
v-if="currentBackup"
|
||||
:backup="currentBackup"
|
||||
preview
|
||||
class="border-px border-solid border-button-border"
|
||||
/>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { TrashIcon, CalendarIcon } from "@modrinth/assets";
|
||||
import { ConfirmModal } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import BackupItem from "~/components/ui/servers/BackupItem.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupId: string;
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupDeleted"]);
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", backup: Backup | undefined): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const isDeleting = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
const modal = ref<InstanceType<typeof ConfirmModal>>();
|
||||
const currentBackup = ref<Backup | undefined>(undefined);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
const deleteBackup = async () => {
|
||||
if (!props.backupId) {
|
||||
emit("backupDeleted", { success: false, message: "No backup selected" });
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await props.server.backups?.delete(props.backupId);
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupDeleted", { success: true, message: "Backup deleted successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupDeleted", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
function show(backup: Backup) {
|
||||
currentBackup.value = backup;
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
show,
|
||||
});
|
||||
</script>
|
||||
|
||||
340
apps/frontend/src/components/ui/servers/BackupItem.vue
Normal file
340
apps/frontend/src/components/ui/servers/BackupItem.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
HistoryIcon,
|
||||
DownloadIcon,
|
||||
SpinnerIcon,
|
||||
EditIcon,
|
||||
LockIcon,
|
||||
TrashIcon,
|
||||
FolderArchiveIcon,
|
||||
BotIcon,
|
||||
XIcon,
|
||||
LockOpenIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { ref } from "vue";
|
||||
import type { Backup } from "~/composables/pyroServers.ts";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "prepare" | "download" | "rename" | "restore" | "lock" | "retry"): void;
|
||||
(e: "delete", skipConfirmation?: boolean): void;
|
||||
}>();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
backup: Backup;
|
||||
preview?: boolean;
|
||||
kyrosUrl?: string;
|
||||
jwt?: string;
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
kyrosUrl: undefined,
|
||||
jwt: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const backupQueued = computed(
|
||||
() =>
|
||||
props.backup.task?.create?.progress === 0 ||
|
||||
(props.backup.ongoing && !props.backup.task?.create),
|
||||
);
|
||||
const automated = computed(() => props.backup.automated);
|
||||
const failedToCreate = computed(() => props.backup.interrupted);
|
||||
|
||||
const preparedDownloadStates = ["ready", "done"];
|
||||
const inactiveStates = ["failed", "cancelled"];
|
||||
|
||||
const hasPreparedDownload = computed(() =>
|
||||
preparedDownloadStates.includes(props.backup.task?.file?.state ?? ""),
|
||||
);
|
||||
|
||||
const creating = computed(() => {
|
||||
const task = props.backup.task?.create;
|
||||
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
|
||||
return task;
|
||||
}
|
||||
if (props.backup.ongoing) {
|
||||
return {
|
||||
progress: 0,
|
||||
state: "ongoing",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const restoring = computed(() => {
|
||||
const task = props.backup.task?.restore;
|
||||
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
|
||||
return task;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const initiatedPrepare = ref(false);
|
||||
|
||||
const preparingFile = computed(() => {
|
||||
const task = props.backup.task?.file;
|
||||
return (
|
||||
(!task && initiatedPrepare.value) ||
|
||||
(task && task.progress < 1 && !inactiveStates.includes(task.state))
|
||||
);
|
||||
});
|
||||
|
||||
const failedToRestore = computed(() => props.backup.task?.restore?.state === "failed");
|
||||
const failedToPrepareFile = computed(() => props.backup.task?.file?.state === "failed");
|
||||
|
||||
const messages = defineMessages({
|
||||
locked: {
|
||||
id: "servers.backups.item.locked",
|
||||
defaultMessage: "Locked",
|
||||
},
|
||||
lock: {
|
||||
id: "servers.backups.item.lock",
|
||||
defaultMessage: "Lock",
|
||||
},
|
||||
unlock: {
|
||||
id: "servers.backups.item.unlock",
|
||||
defaultMessage: "Unlock",
|
||||
},
|
||||
restore: {
|
||||
id: "servers.backups.item.restore",
|
||||
defaultMessage: "Restore",
|
||||
},
|
||||
rename: {
|
||||
id: "servers.backups.item.rename",
|
||||
defaultMessage: "Rename",
|
||||
},
|
||||
queuedForBackup: {
|
||||
id: "servers.backups.item.queued-for-backup",
|
||||
defaultMessage: "Queued for backup",
|
||||
},
|
||||
preparingDownload: {
|
||||
id: "servers.backups.item.preparing-download",
|
||||
defaultMessage: "Preparing download...",
|
||||
},
|
||||
prepareDownload: {
|
||||
id: "servers.backups.item.prepare-download",
|
||||
defaultMessage: "Prepare download",
|
||||
},
|
||||
prepareDownloadAgain: {
|
||||
id: "servers.backups.item.prepare-download-again",
|
||||
defaultMessage: "Try preparing again",
|
||||
},
|
||||
alreadyPreparing: {
|
||||
id: "servers.backups.item.already-preparing",
|
||||
defaultMessage: "Already preparing backup for download",
|
||||
},
|
||||
creatingBackup: {
|
||||
id: "servers.backups.item.creating-backup",
|
||||
defaultMessage: "Creating backup...",
|
||||
},
|
||||
restoringBackup: {
|
||||
id: "servers.backups.item.restoring-backup",
|
||||
defaultMessage: "Restoring from backup...",
|
||||
},
|
||||
failedToCreateBackup: {
|
||||
id: "servers.backups.item.failed-to-create-backup",
|
||||
defaultMessage: "Failed to create backup",
|
||||
},
|
||||
failedToRestoreBackup: {
|
||||
id: "servers.backups.item.failed-to-restore-backup",
|
||||
defaultMessage: "Failed to restore from backup",
|
||||
},
|
||||
failedToPrepareFile: {
|
||||
id: "servers.backups.item.failed-to-prepare-backup",
|
||||
defaultMessage: "Failed to prepare download",
|
||||
},
|
||||
automated: {
|
||||
id: "servers.backups.item.automated",
|
||||
defaultMessage: "Automated",
|
||||
},
|
||||
retry: {
|
||||
id: "servers.backups.item.retry",
|
||||
defaultMessage: "Retry",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
preview
|
||||
? 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr]'
|
||||
: 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr_2fr]'
|
||||
"
|
||||
class="grid items-center gap-4 rounded-2xl bg-bg-raised px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="creating"
|
||||
class="h-6 w-6 animate-spin"
|
||||
:class="{ 'text-orange': backupQueued, 'text-green': !backupQueued }"
|
||||
/>
|
||||
<FolderArchiveIcon v-else class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="col-span-2 flex flex-col gap-1 sm:col-span-1">
|
||||
<span class="font-bold text-contrast">
|
||||
{{ backup.name }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
|
||||
<LockIcon /> {{ formatMessage(messages.locked) }}
|
||||
</span>
|
||||
<span v-if="automated && backup.locked">•</span>
|
||||
<span v-if="automated" class="flex items-center gap-1 text-secondary">
|
||||
<BotIcon /> {{ formatMessage(messages.automated) }}
|
||||
</span>
|
||||
<span v-if="(failedToCreate || failedToRestore) && (automated || backup.locked)">•</span>
|
||||
<span
|
||||
v-if="failedToCreate || failedToRestore || failedToPrepareFile"
|
||||
class="flex items-center gap-1 text-sm text-red"
|
||||
>
|
||||
<XIcon />
|
||||
{{
|
||||
formatMessage(
|
||||
failedToCreate
|
||||
? messages.failedToCreateBackup
|
||||
: failedToRestore
|
||||
? messages.failedToRestoreBackup
|
||||
: messages.failedToPrepareFile,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="creating" class="col-span-2 flex flex-col gap-3">
|
||||
<span v-if="backupQueued" class="text-orange">
|
||||
{{ formatMessage(messages.queuedForBackup) }}
|
||||
</span>
|
||||
<span v-else class="text-green"> {{ formatMessage(messages.creatingBackup) }} </span>
|
||||
<ProgressBar
|
||||
:progress="creating.progress"
|
||||
:color="backupQueued ? 'orange' : 'green'"
|
||||
:waiting="creating.progress === 0"
|
||||
class="max-w-full"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="restoring" class="col-span-2 flex flex-col gap-3 text-purple">
|
||||
{{ formatMessage(messages.restoringBackup) }}
|
||||
<ProgressBar
|
||||
:progress="restoring.progress"
|
||||
color="purple"
|
||||
:waiting="restoring.progress === 0"
|
||||
class="max-w-full"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="col-span-2">
|
||||
{{ dayjs(backup.created_at).format("MMMM D, YYYY [at] h:mm A") }}
|
||||
</div>
|
||||
<div v-if="false">{{ 245 }} MiB</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="!preview"
|
||||
class="col-span-full flex justify-normal gap-2 md:col-span-1 md:justify-end"
|
||||
>
|
||||
<template v-if="failedToCreate">
|
||||
<ButtonStyled>
|
||||
<button @click="() => emit('retry')">
|
||||
<RotateCounterClockwiseIcon />
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="() => emit('delete', true)">
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else-if="creating">
|
||||
<button @click="() => emit('delete')">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-else>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
v-if="hasPreparedDownload"
|
||||
:class="{
|
||||
disabled: !kyrosUrl || !jwt,
|
||||
}"
|
||||
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
|
||||
@click="() => emit('download')"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(commonMessages.downloadButton) }}
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
:disabled="!!preparingFile"
|
||||
@click="
|
||||
() => {
|
||||
initiatedPrepare = true;
|
||||
emit('prepare');
|
||||
}
|
||||
"
|
||||
>
|
||||
<SpinnerIcon v-if="preparingFile" class="animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
formatMessage(
|
||||
preparingFile
|
||||
? messages.preparingDownload
|
||||
: failedToPrepareFile
|
||||
? messages.prepareDownloadAgain
|
||||
: messages.prepareDownload,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{ id: 'rename', action: () => emit('rename') },
|
||||
{
|
||||
id: 'restore',
|
||||
action: () => emit('restore'),
|
||||
disabled: !!restoring || !!preparingFile,
|
||||
},
|
||||
{ id: 'lock', action: () => emit('lock') },
|
||||
{ divider: true },
|
||||
{
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
action: () => emit('delete'),
|
||||
disabled: !!restoring || !!preparingFile,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #rename> <EditIcon /> {{ formatMessage(messages.rename) }} </template>
|
||||
<template #restore> <HistoryIcon /> {{ formatMessage(messages.restore) }} </template>
|
||||
<template v-if="backup.locked" #lock>
|
||||
<LockOpenIcon /> {{ formatMessage(messages.unlock) }}
|
||||
</template>
|
||||
<template v-else #lock> <LockIcon /> {{ formatMessage(messages.lock) }} </template>
|
||||
<template #delete>
|
||||
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
<pre
|
||||
v-if="!preview && flags.advancedDebugInfo"
|
||||
class="col-span-full m-0 rounded-xl bg-button-bg text-xs"
|
||||
>{{ backup }}</pre
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +1,41 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<label for="backup-name-input">
|
||||
<span class="text-lg font-semibold text-contrast"> Name </span>
|
||||
</label>
|
||||
<input
|
||||
id="backup-name-input"
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. Before 1.21"
|
||||
:placeholder="`Backup #${backupNumber}`"
|
||||
maxlength="48"
|
||||
/>
|
||||
<div v-if="nameExists" class="flex items-center gap-1">
|
||||
<IssuesIcon class="hidden text-orange sm:block" />
|
||||
<span class="text-sm text-orange">
|
||||
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
|
||||
>'
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<div class="mt-2 flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRenaming" @click="renameBackup">
|
||||
<SaveIcon />
|
||||
Rename backup
|
||||
<button :disabled="isRenaming || nameExists" @click="renameBackup">
|
||||
<template v-if="isRenaming">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Renaming...
|
||||
</template>
|
||||
<template v-else>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hideModal">
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
@@ -28,23 +45,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { SaveIcon, XIcon } from "@modrinth/assets";
|
||||
import { SpinnerIcon, SaveIcon, XIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
currentBackupId: string;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupRenamed"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const backupName = ref("");
|
||||
const originalName = ref("");
|
||||
const isRenaming = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
|
||||
const currentBackup = ref<Backup | null>(null);
|
||||
|
||||
const trimmedName = computed(() => backupName.value.trim());
|
||||
|
||||
const nameExists = computed(() => {
|
||||
if (!props.server.backups?.data || trimmedName.value === originalName.value || isRenaming.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return props.server.backups.data.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
);
|
||||
});
|
||||
|
||||
const backupNumber = computed(
|
||||
() => (props.server.backups?.data?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
|
||||
);
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
@@ -54,33 +86,55 @@ const focusInput = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const hideModal = () => {
|
||||
backupName.value = "";
|
||||
function show(backup: Backup) {
|
||||
currentBackup.value = backup;
|
||||
backupName.value = backup.name;
|
||||
originalName.value = backup.name;
|
||||
isRenaming.value = false;
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide();
|
||||
};
|
||||
}
|
||||
|
||||
const renameBackup = async () => {
|
||||
if (!backupName.value.trim() || !props.currentBackupId) {
|
||||
emit("backupRenamed", { success: false, message: "Backup name cannot be empty" });
|
||||
if (!currentBackup.value) {
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error renaming backup",
|
||||
text: "Current backup is null",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedName.value === originalName.value) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
isRenaming.value = true;
|
||||
try {
|
||||
await props.server.backups?.rename(props.currentBackupId, backupName.value);
|
||||
let newName = trimmedName.value;
|
||||
|
||||
if (newName.length === 0) {
|
||||
newName = `Backup #${backupNumber.value}`;
|
||||
}
|
||||
|
||||
await props.server.backups?.rename(currentBackup.value.id, newName);
|
||||
hide();
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupRenamed", { success: true, message: "Backup renamed successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupRenamed", { success: false, message: backupError.value });
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
addNotification({ type: "error", title: "Error renaming backup", text: message });
|
||||
} finally {
|
||||
hide();
|
||||
isRenaming.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
show,
|
||||
hide,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,82 +1,58 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Restoring backup">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-bg p-6">
|
||||
<div class="text-2xl font-extrabold text-contrast">
|
||||
{{ backupName }}
|
||||
</div>
|
||||
<div class="flex gap-2 font-semibold text-contrast">
|
||||
<CalendarIcon />
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRestoring" @click="restoreBackup">Restore backup</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="hideModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
danger
|
||||
title="Are you sure you want to restore from this backup?"
|
||||
proceed-label="Restore from backup"
|
||||
description="This will **overwrite all files on your server** and replace them with the files from the backup."
|
||||
@proceed="restoreBackup"
|
||||
>
|
||||
<BackupItem
|
||||
v-if="currentBackup"
|
||||
:backup="currentBackup"
|
||||
preview
|
||||
class="border-px border-solid border-button-border"
|
||||
/>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { CalendarIcon } from "@modrinth/assets";
|
||||
import { ConfirmModal, NewModal } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import BackupItem from "~/components/ui/servers/BackupItem.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupId: string;
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupRestored"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const isRestoring = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
const currentBackup = ref<Backup | null>(null);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
function show(backup: Backup) {
|
||||
currentBackup.value = backup;
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (!props.backupId) {
|
||||
emit("backupRestored", { success: false, message: "No backup selected" });
|
||||
if (!currentBackup.value) {
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Failed to restore backup",
|
||||
text: "Current backup is null",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isRestoring.value = true;
|
||||
try {
|
||||
await props.server.backups?.restore(props.backupId);
|
||||
hideModal();
|
||||
emit("backupRestored", { success: true, message: "Backup restored successfully" });
|
||||
await props.server.backups?.restore(currentBackup.value.id);
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupRestored", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isRestoring.value = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
addNotification({ type: "error", title: "Failed to restore backup", text: message });
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
show,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
:column="true"
|
||||
class="mb-6 flex flex-col gap-2"
|
||||
/>
|
||||
<div v-if="flags.advancedDebugInfo" class="markdown-body">
|
||||
<pre>{{ serverData }}</pre>
|
||||
</div>
|
||||
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
|
||||
<button class="w-full">Close</button>
|
||||
</ButtonStyled>
|
||||
@@ -89,6 +92,10 @@
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
<span>Copy ID</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -108,6 +115,7 @@ import {
|
||||
ServerIcon,
|
||||
InfoIcon,
|
||||
MoreVerticalIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -116,6 +124,8 @@ import { useStorage } from "@vueuse/core";
|
||||
type ServerAction = "start" | "stop" | "restart" | "kill";
|
||||
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
interface PowerAction {
|
||||
action: ServerAction;
|
||||
nextState: ServerState;
|
||||
@@ -198,8 +208,19 @@ const menuOptions = computed(() => [
|
||||
icon: InfoIcon,
|
||||
action: () => detailsModal.value?.show(),
|
||||
},
|
||||
{
|
||||
id: "copy-id",
|
||||
label: "Copy ID",
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => copyId(),
|
||||
shown: flags.value.developerMode,
|
||||
},
|
||||
]);
|
||||
|
||||
async function copyId() {
|
||||
await navigator.clipboard.writeText(serverId as string);
|
||||
}
|
||||
|
||||
function initiateAction(action: ServerAction) {
|
||||
if (!canTakeAction.value) return;
|
||||
|
||||
|
||||
@@ -67,37 +67,27 @@
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="backup-server-mrpack">
|
||||
Backup server
|
||||
</label>
|
||||
<input
|
||||
id="backup-server-mrpack"
|
||||
v-model="backupServer"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>Creates a backup of your server before proceeding.</div>
|
||||
</div>
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
||||
</div>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button :disabled="canInstall" @click="handleReinstall">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="canInstall || backupInProgress"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isBackingUp
|
||||
? "Backing up..."
|
||||
: isMrpackModalSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
isMrpackModalSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -124,12 +114,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -139,9 +131,7 @@ const emit = defineEmits<{
|
||||
const mrpackModal = ref();
|
||||
const isMrpackModalSecondPhase = ref(false);
|
||||
const hardReset = ref(false);
|
||||
const backupServer = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isBackingUp = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const mrpackFile = ref<File | null>(null);
|
||||
|
||||
@@ -156,64 +146,12 @@ const uploadMrpack = (event: Event) => {
|
||||
mrpackFile.value = target.files[0];
|
||||
};
|
||||
|
||||
const performBackup = async (): Promise<boolean> => {
|
||||
try {
|
||||
const date = new Date();
|
||||
const format = date.toLocaleString(navigator.language || "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
const backupName = `Reinstallation - ${format}`;
|
||||
isLoading.value = true;
|
||||
const backupId = await props.server.backups?.create(backupName);
|
||||
isBackingUp.value = true;
|
||||
let attempts = 0;
|
||||
while (true) {
|
||||
attempts++;
|
||||
if (attempts > 100) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await props.server.refresh(["backups"]);
|
||||
const backups = await props.server.backups?.data;
|
||||
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||
if (backup && !backup.ongoing) {
|
||||
isBackingUp.value = false;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (hardReset.value && !backupServer.value && !isMrpackModalSecondPhase.value) {
|
||||
if (hardReset.value && !isMrpackModalSecondPhase.value) {
|
||||
isMrpackModalSecondPhase.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (backupServer.value && !(await performBackup())) {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
@@ -259,7 +197,6 @@ const handleReinstall = async () => {
|
||||
|
||||
const onShow = () => {
|
||||
hardReset.value = false;
|
||||
backupServer.value = false;
|
||||
isMrpackModalSecondPhase.value = false;
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
}"
|
||||
>
|
||||
{{
|
||||
backupServer
|
||||
? "A backup will be created before proceeding with the reinstallation, then all data will be erased from your server. Are you sure you want to continue?"
|
||||
: "This will reinstall your server and erase all data. Are you sure you want to continue?"
|
||||
"This will reinstall your server and erase all data. Are you sure you want to continue?"
|
||||
}}
|
||||
</p>
|
||||
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
|
||||
@@ -131,41 +129,28 @@
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="backup-server">
|
||||
Backup server
|
||||
</label>
|
||||
<input
|
||||
id="backup-server"
|
||||
v-model="backupServer"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Creates a backup of your server before proceeding with the installation or
|
||||
reinstallation.
|
||||
</div>
|
||||
</div>
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button :disabled="canInstall" @click="handleReinstall">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="canInstall || !!backupInProgress"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isBackingUp
|
||||
? "Backing up..."
|
||||
: isLoading
|
||||
? "Installing..."
|
||||
: isSecondPhase
|
||||
? "Erase and install"
|
||||
: hardReset
|
||||
? "Continue"
|
||||
: "Install"
|
||||
isLoading
|
||||
? "Installing..."
|
||||
: isSecondPhase
|
||||
? "Erase and install"
|
||||
: hardReset
|
||||
? "Continue"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -192,10 +177,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
interface LoaderVersion {
|
||||
id: string;
|
||||
@@ -213,6 +201,7 @@ type VersionCache = Record<string, any>;
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
currentLoader: Loaders | undefined;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -222,9 +211,7 @@ const emit = defineEmits<{
|
||||
const versionSelectModal = ref();
|
||||
const isSecondPhase = ref(false);
|
||||
const hardReset = ref(false);
|
||||
const backupServer = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isBackingUp = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const serverCheckError = ref("");
|
||||
|
||||
@@ -413,69 +400,12 @@ const canInstall = computed(() => {
|
||||
return conds || !selectedLoaderVersion.value;
|
||||
});
|
||||
|
||||
const performBackup = async (): Promise<boolean> => {
|
||||
try {
|
||||
const date = new Date();
|
||||
const format = date.toLocaleString(navigator.language || "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
const backupName = `Reinstallation - ${format}`;
|
||||
isLoading.value = true;
|
||||
const backupId = await props.server.backups?.create(backupName);
|
||||
isBackingUp.value = true;
|
||||
let attempts = 0;
|
||||
while (true) {
|
||||
attempts++;
|
||||
if (attempts > 100) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await props.server.refresh(["backups"]);
|
||||
const backups = await props.server.backups?.data;
|
||||
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||
if (backup && !backup.ongoing) {
|
||||
isBackingUp.value = false;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (hardReset.value && !isSecondPhase.value) {
|
||||
isSecondPhase.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (backupServer.value) {
|
||||
isBackingUp.value = true;
|
||||
if (!(await performBackup())) {
|
||||
isBackingUp.value = false;
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
isBackingUp.value = false;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
@@ -522,7 +452,6 @@ const onShow = () => {
|
||||
|
||||
const onHide = () => {
|
||||
hardReset.value = false;
|
||||
backupServer.value = false;
|
||||
isSecondPhase.value = false;
|
||||
serverCheckError.value = "";
|
||||
loadingServerCheck.value = false;
|
||||
|
||||
@@ -21,7 +21,12 @@
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full">
|
||||
<NuxtPage :route="props.route" :server="props.server" @reinstall="onReinstall" />
|
||||
<NuxtPage
|
||||
:route="route"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@reinstall="onReinstall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,13 +35,15 @@
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const emit = defineEmits(["reinstall"]);
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||
route: RouteLocationNormalized;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const onReinstall = (...args: any[]) => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { RightArrowIcon, SparklesIcon, UnknownIcon } from "@modrinth/assets";
|
||||
import type { MessageDescriptor } from "@vintl/vintl";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select" | "scroll-to-faq"): void;
|
||||
}>();
|
||||
|
||||
type Plan = "small" | "medium" | "large";
|
||||
|
||||
const plans: Record<
|
||||
Plan,
|
||||
{
|
||||
buttonColor: "blue" | "green" | "purple";
|
||||
accentText: string;
|
||||
accentBg: string;
|
||||
name: MessageDescriptor;
|
||||
symbol: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
}
|
||||
> = {
|
||||
small: {
|
||||
buttonColor: "blue",
|
||||
accentText: "text-blue",
|
||||
accentBg: "bg-bg-blue",
|
||||
name: defineMessage({
|
||||
id: "servers.plan.small.name",
|
||||
defaultMessage: "Small",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.small.symbol",
|
||||
defaultMessage: "S",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.small.description",
|
||||
defaultMessage:
|
||||
"Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.",
|
||||
}),
|
||||
},
|
||||
medium: {
|
||||
buttonColor: "green",
|
||||
accentText: "text-green",
|
||||
accentBg: "bg-bg-green",
|
||||
name: defineMessage({
|
||||
id: "servers.plan.medium.name",
|
||||
defaultMessage: "Medium",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.medium.symbol",
|
||||
defaultMessage: "M",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.medium.description",
|
||||
defaultMessage: "Great for modded multiplayer and small communities.",
|
||||
}),
|
||||
},
|
||||
large: {
|
||||
buttonColor: "purple",
|
||||
accentText: "text-purple",
|
||||
accentBg: "bg-bg-purple",
|
||||
name: defineMessage({
|
||||
id: "servers.plan.large.name",
|
||||
defaultMessage: "Large",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.large.symbol",
|
||||
defaultMessage: "L",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.large.description",
|
||||
defaultMessage: "Ideal for larger communities, modpacks, and heavy modding.",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
capacity?: number;
|
||||
plan: Plan;
|
||||
ram: number;
|
||||
storage: number;
|
||||
cpus: number;
|
||||
price: number;
|
||||
}>();
|
||||
|
||||
const outOfStock = computed(() => {
|
||||
return !props.capacity || props.capacity === 0;
|
||||
});
|
||||
|
||||
const lowStock = computed(() => {
|
||||
return !props.capacity || props.capacity < 8;
|
||||
});
|
||||
|
||||
const formattedRam = computed(() => {
|
||||
return props.ram / 1024;
|
||||
});
|
||||
|
||||
const formattedStorage = computed(() => {
|
||||
return props.storage / 1024;
|
||||
});
|
||||
|
||||
const sharedCpus = computed(() => {
|
||||
return props.cpus / 2;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="lowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl p-4 text-center font-bold"
|
||||
:class="outOfStock ? 'bg-bg-red' : 'bg-bg-orange'"
|
||||
>
|
||||
<template v-if="outOfStock"> Out of stock! </template>
|
||||
<template v-else> Only {{ capacity }} left in stock! </template>
|
||||
</div>
|
||||
<div
|
||||
:style="
|
||||
plan === 'medium'
|
||||
? {
|
||||
background: `radial-gradient(
|
||||
86.12% 101.64% at 95.97% 94.07%,
|
||||
rgba(27, 217, 106, 0.23) 0%,
|
||||
rgba(14, 115, 56, 0.2) 100%
|
||||
)`,
|
||||
border: `1px solid rgba(12, 107, 52, 0.55)`,
|
||||
'box-shadow': `0px 12px 38.1px rgba(27, 217, 106, 0.13)`,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||
:class="{ '!rounded-t-none': lowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="m-0">{{ formatMessage(plans[plan].name) }}</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full text-xs font-bold"
|
||||
:class="`${plans[plan].accentBg} ${plans[plan].accentText}`"
|
||||
>
|
||||
{{ formatMessage(plans[plan].symbol) }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">{{ formatMessage(plans[plan].description) }}</p>
|
||||
<div
|
||||
class="flex flex-row flex-wrap items-center gap-2 text-nowrap text-secondary xl:justify-between"
|
||||
>
|
||||
<p class="m-0">{{ formattedRam }} GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">{{ formattedStorage }} GB SSD</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">{{ sharedCpus }} Shared CPUs</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-secondary">
|
||||
<SparklesIcon /> Bursts up to {{ cpus }} CPUs
|
||||
<nuxt-link
|
||||
v-tooltip="
|
||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
||||
"
|
||||
to="/servers#cpu-burst"
|
||||
@click="() => emit('scroll-to-faq')"
|
||||
>
|
||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<span class="m-0 text-2xl font-bold text-contrast">
|
||||
${{ price / 100 }}<span class="text-lg font-semibold text-secondary">/month</span>
|
||||
</span>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
:color="plans[plan].buttonColor"
|
||||
:type="plan === 'medium' ? 'standard' : 'highlight-colored-text'"
|
||||
size="large"
|
||||
>
|
||||
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
|
||||
<button v-else @click="() => emit('select')">
|
||||
Get Started
|
||||
<RightArrowIcon class="shrink-0" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { Accordion, ButtonStyled, NewModal, ServerNotice, TagItem } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon } from "@modrinth/assets";
|
||||
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import { ref } from "vue";
|
||||
import { usePyroFetch } from "~/composables/pyroFetch.ts";
|
||||
|
||||
const app = useNuxtApp() as unknown as { $notify: any };
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const notice = ref<ServerNoticeType>();
|
||||
|
||||
const assigned = ref<ServerNoticeType["assigned"]>([]);
|
||||
|
||||
const assignedServers = computed(() => assigned.value.filter((n) => n.kind === "server") ?? []);
|
||||
const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === "node") ?? []);
|
||||
|
||||
const inputField = ref("");
|
||||
|
||||
async function refresh() {
|
||||
await usePyroFetch("notices").then((res) => {
|
||||
const notices = res as ServerNoticeType[];
|
||||
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
async function assign(server: boolean = true) {
|
||||
const input = inputField.value.trim();
|
||||
|
||||
if (input !== "" && notice.value) {
|
||||
await usePyroFetch(`notices/${notice.value.id}/assign?${server ? "server" : "node"}=${input}`, {
|
||||
method: "PUT",
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error assigning notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error assigning notice",
|
||||
text: "No server or node specified",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function unassignDetect() {
|
||||
const input = inputField.value.trim();
|
||||
|
||||
const server = assignedServers.value.some((assigned) => assigned.id === input);
|
||||
const node = assignedNodes.value.some((assigned) => assigned.id === input);
|
||||
|
||||
if (!server && !node) {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error unassigning notice",
|
||||
text: "ID is not an assigned server or node",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await unassign(input, server);
|
||||
}
|
||||
|
||||
async function unassign(id: string, server: boolean = true) {
|
||||
if (notice.value) {
|
||||
await usePyroFetch(`notices/${notice.value.id}/unassign?${server ? "server" : "node"}=${id}`, {
|
||||
method: "PUT",
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error unassigning notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function show(currentNotice: ServerNoticeType) {
|
||||
notice.value = currentNotice;
|
||||
assigned.value = currentNotice?.assigned ?? [];
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide();
|
||||
}
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
<template>
|
||||
<NewModal ref="modal" :on-hide="() => emit('close')">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
Editing assignments of notice #{{ notice?.id }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<ServerNotice
|
||||
v-if="notice"
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
preview
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="server-assign-field" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Assigned servers </span>
|
||||
</label>
|
||||
<Accordion
|
||||
v-if="assignedServers.length > 0"
|
||||
class="mb-2"
|
||||
open-by-default
|
||||
button-class="text-primary m-0 p-0 border-none bg-transparent active:scale-95"
|
||||
>
|
||||
<template #title> {{ assignedServers.length }} servers </template>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<TagItem
|
||||
v-for="server in assignedServers"
|
||||
:key="`server-${server.id}`"
|
||||
:action="() => unassign(server.id, true)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ server.id }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</Accordion>
|
||||
<span v-else class="mb-2"> No servers assigned yet </span>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="server-assign-field" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Assigned nodes </span>
|
||||
</label>
|
||||
<Accordion
|
||||
v-if="assignedNodes.length > 0"
|
||||
class="mb-2"
|
||||
open-by-default
|
||||
button-class="text-primary m-0 p-0 border-none bg-transparent active:scale-95"
|
||||
>
|
||||
<template #title> {{ assignedNodes.length }} nodes </template>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<TagItem
|
||||
v-for="node in assignedNodes"
|
||||
:key="`node-${node.id}`"
|
||||
:action="
|
||||
() => {
|
||||
unassign(node.id, false);
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ node.id }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</Accordion>
|
||||
<span v-else class="mb-2"> No nodes assigned yet </span>
|
||||
</div>
|
||||
<div class="flex w-[45rem] items-center gap-2">
|
||||
<input
|
||||
id="server-assign-field"
|
||||
v-model="inputField"
|
||||
class="w-full"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ButtonStyled color="green" color-fill="text">
|
||||
<button class="shrink-0" @click="() => assign(true)">
|
||||
<PlusIcon />
|
||||
Add server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
<button class="shrink-0" @click="() => assign(false)">
|
||||
<PlusIcon />
|
||||
Add node
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" color-fill="text">
|
||||
<button class="shrink-0" @click="() => unassignDetect()">
|
||||
<XIcon />
|
||||
Remove
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import { ButtonStyled, commonMessages, CopyCode, ServerNotice, TagItem } from "@modrinth/ui";
|
||||
import { EditIcon, SettingsIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import {
|
||||
DISMISSABLE,
|
||||
getDismissableMetadata,
|
||||
NOTICE_LEVELS,
|
||||
} from "@modrinth/ui/src/utils/notices.ts";
|
||||
import { useVIntl } from "@vintl/vintl";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const props = defineProps<{
|
||||
notice: ServerNoticeType;
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<div class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4">
|
||||
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
|
||||
<div>
|
||||
<CopyCode :text="`${notice.id}`" />
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span v-if="notice.announce_at">
|
||||
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
|
||||
dayjs(notice.announce_at).fromNow()
|
||||
}})
|
||||
</span>
|
||||
<template v-else> Never begins </template>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span
|
||||
v-if="notice.expires"
|
||||
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
{{ dayjs(notice.expires).fromNow() }}
|
||||
</span>
|
||||
<template v-else> Never expires </template>
|
||||
</div>
|
||||
<div
|
||||
:style="
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? {
|
||||
'--_color': NOTICE_LEVELS[notice.level].colors.text,
|
||||
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<TagItem>
|
||||
{{
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? formatMessage(NOTICE_LEVELS[notice.level].name)
|
||||
: notice.level
|
||||
}}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
'--_color': getDismissableMetadata(notice.dismissable).colors.text,
|
||||
'--_bg-color': getDismissableMetadata(notice.dismissable).colors.bg,
|
||||
}"
|
||||
>
|
||||
<TagItem>
|
||||
{{ formatMessage(getDismissableMetadata(notice.dismissable).name) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="col-span-2 flex gap-2 md:col-span-1">
|
||||
<ButtonStyled>
|
||||
<button @click="() => startEditing(notice)">
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="() => deleteNotice(notice)">
|
||||
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full grid">
|
||||
<ServerNotice
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
preview
|
||||
/>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span v-if="!notice.assigned || notice.assigned.length === 0"
|
||||
>Not assigned to any servers</span
|
||||
>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
|
||||
</span>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers
|
||||
</span>
|
||||
<span v-else>
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers and
|
||||
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
|
||||
</span>
|
||||
•
|
||||
<button
|
||||
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
|
||||
@click="() => startEditing(notice, true)"
|
||||
>
|
||||
<SettingsIcon />
|
||||
Edit assignments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,6 +30,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
newProjectCards: false,
|
||||
projectBackground: false,
|
||||
searchBackground: false,
|
||||
advancedDebugInfo: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ interface PyroFetchOptions {
|
||||
token?: string;
|
||||
};
|
||||
retry?: boolean;
|
||||
bypassAuth?: boolean;
|
||||
}
|
||||
|
||||
export class PyroFetchError extends Error {
|
||||
@@ -28,7 +29,7 @@ export async function usePyroFetch<T>(path: string, options: PyroFetchOptions =
|
||||
const auth = await useAuth();
|
||||
const authToken = auth.value?.token;
|
||||
|
||||
if (!authToken) {
|
||||
if (!authToken && !options.bypassAuth) {
|
||||
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
|
||||
}
|
||||
|
||||
@@ -52,9 +53,15 @@ export async function usePyroFetch<T>(path: string, options: PyroFetchOptions =
|
||||
|
||||
type HeadersRecord = Record<string, string>;
|
||||
|
||||
const authHeader: HeadersRecord = options.bypassAuth
|
||||
? {}
|
||||
: {
|
||||
Authorization: `Bearer ${override?.token ?? authToken}`,
|
||||
"Access-Control-Allow-Headers": "Authorization",
|
||||
};
|
||||
|
||||
const headers: HeadersRecord = {
|
||||
Authorization: `Bearer ${override?.token ?? authToken}`,
|
||||
"Access-Control-Allow-Headers": "Authorization",
|
||||
...authHeader,
|
||||
"User-Agent": "Pyro/1.0 (https://pyro.host)",
|
||||
Vary: "Accept, Origin",
|
||||
"Content-Type": contentType,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// usePyroServer is a composable that interfaces with the REDACTED API to get data and control the users server
|
||||
import { $fetch, FetchError } from "ofetch";
|
||||
import type { ServerNotice } from "@modrinth/utils";
|
||||
import type { WSBackupState, WSBackupTask } from "~/types/servers.ts";
|
||||
|
||||
interface PyroFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
@@ -192,7 +194,7 @@ async function PyroFetch<T>(
|
||||
throw lastError || new Error("Maximum retry attempts reached");
|
||||
}
|
||||
|
||||
const internalServerRefrence = ref<any>(null);
|
||||
const internalServerReference = ref<any>(null);
|
||||
|
||||
interface License {
|
||||
id: string;
|
||||
@@ -289,6 +291,11 @@ interface General {
|
||||
sftp_password: string;
|
||||
sftp_host: string;
|
||||
datacenter?: string;
|
||||
notices?: ServerNotice[];
|
||||
node: {
|
||||
token: string;
|
||||
instance: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Allocation {
|
||||
@@ -315,12 +322,20 @@ export interface Mod {
|
||||
installing: boolean;
|
||||
}
|
||||
|
||||
interface Backup {
|
||||
export interface Backup {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
ongoing: boolean;
|
||||
locked: boolean;
|
||||
automated: boolean;
|
||||
interrupted: boolean;
|
||||
ongoing: boolean;
|
||||
task: {
|
||||
[K in WSBackupTask]?: {
|
||||
progress: number;
|
||||
state: WSBackupState;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface AutoBackupSettings {
|
||||
@@ -368,7 +383,7 @@ const constructServerProperties = (properties: any): string => {
|
||||
|
||||
const processImage = async (iconUrl: string | undefined) => {
|
||||
const sharedImage = useState<string | undefined>(
|
||||
`server-icon-${internalServerRefrence.value.serverId}`,
|
||||
`server-icon-${internalServerReference.value.serverId}`,
|
||||
);
|
||||
|
||||
if (sharedImage.value) {
|
||||
@@ -376,7 +391,7 @@ const processImage = async (iconUrl: string | undefined) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerReference.value.serverId}/fs`);
|
||||
try {
|
||||
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
@@ -463,13 +478,13 @@ const processImage = async (iconUrl: string | undefined) => {
|
||||
|
||||
const sendPowerAction = async (action: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/power`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/power`, {
|
||||
method: "POST",
|
||||
body: { action },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await internalServerRefrence.value.refresh();
|
||||
await internalServerReference.value.refresh();
|
||||
} catch (error) {
|
||||
console.error("Error changing power state:", error);
|
||||
throw error;
|
||||
@@ -478,7 +493,7 @@ const sendPowerAction = async (action: string) => {
|
||||
|
||||
const updateName = async (newName: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/name`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/name`, {
|
||||
method: "POST",
|
||||
body: { name: newName },
|
||||
});
|
||||
@@ -522,7 +537,7 @@ const reinstallFromMrpack = async (mrpack: File, hardReset: boolean = false) =>
|
||||
const hardResetParam = hardReset ? "true" : "false";
|
||||
try {
|
||||
const auth = await PyroFetch<JWTAuth>(
|
||||
`servers/${internalServerRefrence.value.serverId}/reinstallFromMrpack`,
|
||||
`servers/${internalServerReference.value.serverId}/reinstallFromMrpack`,
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -551,7 +566,7 @@ const reinstallFromMrpack = async (mrpack: File, hardReset: boolean = false) =>
|
||||
|
||||
const suspendServer = async (status: boolean) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/suspend`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/suspend`, {
|
||||
method: "POST",
|
||||
body: { suspended: status },
|
||||
});
|
||||
@@ -563,7 +578,7 @@ const suspendServer = async (status: boolean) => {
|
||||
|
||||
const fetchConfigFile = async (fileName: string) => {
|
||||
try {
|
||||
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/config/${fileName}`);
|
||||
return await PyroFetch(`servers/${internalServerReference.value.serverId}/config/${fileName}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching config file:", error);
|
||||
throw error;
|
||||
@@ -594,7 +609,7 @@ const setMotd = async (motd: string) => {
|
||||
const newProps = constructServerProperties(props);
|
||||
const octetStream = new Blob([newProps], { type: "application/octet-stream" });
|
||||
const auth = await await PyroFetch<JWTAuth>(
|
||||
`servers/${internalServerRefrence.value.serverId}/fs`,
|
||||
`servers/${internalServerReference.value.serverId}/fs`,
|
||||
);
|
||||
|
||||
return await PyroFetch(`/update?path=/server.properties`, {
|
||||
@@ -613,7 +628,7 @@ const setMotd = async (motd: string) => {
|
||||
|
||||
const installContent = async (contentType: ContentType, projectId: string, versionId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/mods`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
rinth_ids: { project_id: projectId, version_id: versionId },
|
||||
@@ -628,7 +643,7 @@ const installContent = async (contentType: ContentType, projectId: string, versi
|
||||
|
||||
const removeContent = async (path: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/deleteMod`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
path,
|
||||
@@ -642,7 +657,7 @@ const removeContent = async (path: string) => {
|
||||
|
||||
const reinstallContent = async (replace: string, projectId: string, versionId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/update`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/mods/update`, {
|
||||
method: "POST",
|
||||
body: { replace, project_id: projectId, version_id: versionId },
|
||||
});
|
||||
@@ -657,13 +672,13 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s
|
||||
const createBackup = async (backupName: string) => {
|
||||
try {
|
||||
const response = await PyroFetch<{ id: string }>(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups`,
|
||||
`servers/${internalServerReference.value.serverId}/backups`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { name: backupName },
|
||||
},
|
||||
);
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
return response.id;
|
||||
} catch (error) {
|
||||
console.error("Error creating backup:", error);
|
||||
@@ -673,11 +688,14 @@ const createBackup = async (backupName: string) => {
|
||||
|
||||
const renameBackup = async (backupId: string, newName: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/rename`, {
|
||||
method: "POST",
|
||||
body: { name: newName },
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await PyroFetch(
|
||||
`servers/${internalServerReference.value.serverId}/backups/${backupId}/rename`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { name: newName },
|
||||
},
|
||||
);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error renaming backup:", error);
|
||||
throw error;
|
||||
@@ -686,10 +704,10 @@ const renameBackup = async (backupId: string, newName: string) => {
|
||||
|
||||
const deleteBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error deleting backup:", error);
|
||||
throw error;
|
||||
@@ -699,30 +717,35 @@ const deleteBackup = async (backupId: string) => {
|
||||
const restoreBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/restore`,
|
||||
`servers/${internalServerReference.value.serverId}/backups/${backupId}/restore`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error restoring backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBackup = async (backupId: string) => {
|
||||
const prepareBackup = async (backupId: string) => {
|
||||
try {
|
||||
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`);
|
||||
await PyroFetch(
|
||||
`servers/${internalServerReference.value.serverId}/backups/${backupId}/prepare-download`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error downloading backup:", error);
|
||||
console.error("Error preparing backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAutoBackup = async (autoBackup: "enable" | "disable", interval: number) => {
|
||||
try {
|
||||
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/autobackup`, {
|
||||
return await PyroFetch(`servers/${internalServerReference.value.serverId}/autobackup`, {
|
||||
method: "POST",
|
||||
body: { set: autoBackup, interval },
|
||||
});
|
||||
@@ -734,7 +757,7 @@ const updateAutoBackup = async (autoBackup: "enable" | "disable", interval: numb
|
||||
|
||||
const getAutoBackup = async () => {
|
||||
try {
|
||||
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/autobackup`);
|
||||
return await PyroFetch(`servers/${internalServerReference.value.serverId}/autobackup`);
|
||||
} catch (error) {
|
||||
console.error("Error getting auto backup settings:", error);
|
||||
throw error;
|
||||
@@ -743,10 +766,10 @@ const getAutoBackup = async () => {
|
||||
|
||||
const lockBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}/lock`, {
|
||||
method: "POST",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error locking backup:", error);
|
||||
throw error;
|
||||
@@ -755,22 +778,36 @@ const lockBackup = async (backupId: string) => {
|
||||
|
||||
const unlockBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
|
||||
method: "POST",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await PyroFetch(
|
||||
`servers/${internalServerReference.value.serverId}/backups/${backupId}/unlock`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error unlocking backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const retryBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}/retry`, {
|
||||
method: "POST",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrying backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------ NETWORK ------------------ //
|
||||
|
||||
const reserveAllocation = async (name: string): Promise<Allocation> => {
|
||||
try {
|
||||
return await PyroFetch<Allocation>(
|
||||
`servers/${internalServerRefrence.value.serverId}/allocations?name=${name}`,
|
||||
`servers/${internalServerReference.value.serverId}/allocations?name=${name}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
@@ -784,7 +821,7 @@ const reserveAllocation = async (name: string): Promise<Allocation> => {
|
||||
const updateAllocation = async (port: number, name: string) => {
|
||||
try {
|
||||
await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/allocations/${port}?name=${name}`,
|
||||
`servers/${internalServerReference.value.serverId}/allocations/${port}?name=${name}`,
|
||||
{
|
||||
method: "PUT",
|
||||
},
|
||||
@@ -797,7 +834,7 @@ const updateAllocation = async (port: number, name: string) => {
|
||||
|
||||
const deleteAllocation = async (port: number) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/allocations/${port}`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/allocations/${port}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -817,7 +854,7 @@ const checkSubdomainAvailability = async (subdomain: string): Promise<{ availabl
|
||||
|
||||
const changeSubdomain = async (subdomain: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/subdomain`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/subdomain`, {
|
||||
method: "POST",
|
||||
body: { subdomain },
|
||||
});
|
||||
@@ -835,7 +872,7 @@ const updateStartupSettings = async (
|
||||
jdkBuild: "corretto" | "temurin" | "graal",
|
||||
) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/startup`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/startup`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
invocation: invocation || null,
|
||||
@@ -856,7 +893,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
|
||||
await internalServerRefrence.value.refresh(["fs"]);
|
||||
await internalServerReference.value.refresh(["fs"]);
|
||||
return await requestFn();
|
||||
}
|
||||
|
||||
@@ -868,7 +905,7 @@ const listDirContents = (path: string, page: number, pageSize: number) => {
|
||||
return retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
return await PyroFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
@@ -880,7 +917,7 @@ const createFileOrFolder = (path: string, type: "file" | "directory") => {
|
||||
return await PyroFetch(`/create?path=${encodedPath}&type=${type}`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -923,9 +960,12 @@ const uploadFile = (path: string, file: File) => {
|
||||
|
||||
xhr.open(
|
||||
"POST",
|
||||
`https://${internalServerRefrence.value.fs.auth.url}/create?path=${encodedPath}&type=file`,
|
||||
`https://${internalServerReference.value.fs.auth.url}/create?path=${encodedPath}&type=file`,
|
||||
);
|
||||
xhr.setRequestHeader(
|
||||
"Authorization",
|
||||
`Bearer ${internalServerReference.value.fs.auth.token}`,
|
||||
);
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${internalServerRefrence.value.fs.auth.token}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.send(file);
|
||||
|
||||
@@ -955,7 +995,7 @@ const renameFileOrFolder = (path: string, name: string) => {
|
||||
return retryWithAuth(async () => {
|
||||
await PyroFetch(`/move`, {
|
||||
method: "POST",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
body: {
|
||||
source: path,
|
||||
destination: pathName,
|
||||
@@ -972,7 +1012,7 @@ const updateFile = (path: string, content: string) => {
|
||||
method: "PUT",
|
||||
contentType: "application/octet-stream",
|
||||
body: octetStream,
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1002,7 +1042,7 @@ const moveFileOrFolder = (path: string, newPath: string) => {
|
||||
|
||||
return await PyroFetch(`/move`, {
|
||||
method: "POST",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
body: {
|
||||
source: path,
|
||||
destination: newPath,
|
||||
@@ -1016,7 +1056,7 @@ const deleteFileOrFolder = (path: string, recursive: boolean) => {
|
||||
return retryWithAuth(async () => {
|
||||
return await PyroFetch(`/delete?path=${encodedPath}&recursive=${recursive}`, {
|
||||
method: "DELETE",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1025,7 +1065,7 @@ const downloadFile = (path: string, raw?: boolean) => {
|
||||
return retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const fileData = await PyroFetch(`/download?path=${encodedPath}`, {
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
});
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
@@ -1137,11 +1177,12 @@ const modules: any = {
|
||||
rename: renameBackup,
|
||||
delete: deleteBackup,
|
||||
restore: restoreBackup,
|
||||
download: downloadBackup,
|
||||
prepare: prepareBackup,
|
||||
updateAutoBackup,
|
||||
getAutoBackup,
|
||||
lock: lockBackup,
|
||||
unlock: unlockBackup,
|
||||
retry: retryBackup,
|
||||
},
|
||||
network: {
|
||||
get: async (serverId: string) => {
|
||||
@@ -1382,6 +1423,12 @@ type BackupFunctions = {
|
||||
*/
|
||||
download: (backupId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Prepare a backup for the server.
|
||||
* @param backupId - The ID of the backup.
|
||||
*/
|
||||
prepare: (backupId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates the auto backup settings of the server.
|
||||
* @param autoBackup - Whether to enable auto backup.
|
||||
@@ -1405,6 +1452,12 @@ type BackupFunctions = {
|
||||
* @param backupId - The ID of the backup.
|
||||
*/
|
||||
unlock: (backupId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Retries a failed backup for the server.
|
||||
* @param backupId - The ID of the backup.
|
||||
*/
|
||||
retry: (backupId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type NetworkFunctions = {
|
||||
@@ -1702,7 +1755,7 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable
|
||||
server[module] = modules[module];
|
||||
});
|
||||
|
||||
internalServerRefrence.value = server;
|
||||
internalServerReference.value = server;
|
||||
await server.refresh(initialModules);
|
||||
|
||||
if (deferredModules.length > 0) {
|
||||
|
||||
@@ -298,6 +298,12 @@
|
||||
link: '/admin/user_email',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'servers-notices',
|
||||
color: 'primary',
|
||||
link: '/admin/servers/notices',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
@@ -305,6 +311,9 @@
|
||||
<template #review-projects> <ScaleIcon aria-hidden="true" /> Review projects </template>
|
||||
<template #review-reports> <ReportIcon aria-hidden="true" /> Reports </template>
|
||||
<template #user-lookup> <UserIcon aria-hidden="true" /> Lookup by email </template>
|
||||
<template #servers-notices>
|
||||
<IssuesIcon aria-hidden="true" /> Manage server notices
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
|
||||
@@ -959,6 +959,117 @@
|
||||
"search.filter.locked.server.sync": {
|
||||
"message": "Sync with server"
|
||||
},
|
||||
"servers.backup.create.in-progress.tooltip": {
|
||||
"message": "Backup creation in progress"
|
||||
},
|
||||
"servers.backup.restore.in-progress.tooltip": {
|
||||
"message": "Backup restore in progress"
|
||||
},
|
||||
"servers.backups.item.already-preparing": {
|
||||
"message": "Already preparing backup for download"
|
||||
},
|
||||
"servers.backups.item.automated": {
|
||||
"message": "Automated"
|
||||
},
|
||||
"servers.backups.item.creating-backup": {
|
||||
"message": "Creating backup..."
|
||||
},
|
||||
"servers.backups.item.failed-to-create-backup": {
|
||||
"message": "Failed to create backup"
|
||||
},
|
||||
"servers.backups.item.failed-to-prepare-backup": {
|
||||
"message": "Failed to prepare download"
|
||||
},
|
||||
"servers.backups.item.failed-to-restore-backup": {
|
||||
"message": "Failed to restore from backup"
|
||||
},
|
||||
"servers.backups.item.lock": {
|
||||
"message": "Lock"
|
||||
},
|
||||
"servers.backups.item.locked": {
|
||||
"message": "Locked"
|
||||
},
|
||||
"servers.backups.item.prepare-download": {
|
||||
"message": "Prepare download"
|
||||
},
|
||||
"servers.backups.item.prepare-download-again": {
|
||||
"message": "Try preparing again"
|
||||
},
|
||||
"servers.backups.item.preparing-download": {
|
||||
"message": "Preparing download..."
|
||||
},
|
||||
"servers.backups.item.queued-for-backup": {
|
||||
"message": "Queued for backup"
|
||||
},
|
||||
"servers.backups.item.rename": {
|
||||
"message": "Rename"
|
||||
},
|
||||
"servers.backups.item.restore": {
|
||||
"message": "Restore"
|
||||
},
|
||||
"servers.backups.item.restoring-backup": {
|
||||
"message": "Restoring from backup..."
|
||||
},
|
||||
"servers.backups.item.retry": {
|
||||
"message": "Retry"
|
||||
},
|
||||
"servers.backups.item.unlock": {
|
||||
"message": "Unlock"
|
||||
},
|
||||
"servers.notice.actions": {
|
||||
"message": "Actions"
|
||||
},
|
||||
"servers.notice.begins": {
|
||||
"message": "Begins"
|
||||
},
|
||||
"servers.notice.dismissable": {
|
||||
"message": "Dismissable"
|
||||
},
|
||||
"servers.notice.expires": {
|
||||
"message": "Expires"
|
||||
},
|
||||
"servers.notice.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"servers.notice.level": {
|
||||
"message": "Level"
|
||||
},
|
||||
"servers.notice.undismissable": {
|
||||
"message": "Undismissable"
|
||||
},
|
||||
"servers.notices.create-notice": {
|
||||
"message": "Create notice"
|
||||
},
|
||||
"servers.notices.no-notices": {
|
||||
"message": "No notices"
|
||||
},
|
||||
"servers.plan.large.description": {
|
||||
"message": "Ideal for larger communities, modpacks, and heavy modding."
|
||||
},
|
||||
"servers.plan.large.name": {
|
||||
"message": "Large"
|
||||
},
|
||||
"servers.plan.large.symbol": {
|
||||
"message": "L"
|
||||
},
|
||||
"servers.plan.medium.description": {
|
||||
"message": "Great for modded multiplayer and small communities."
|
||||
},
|
||||
"servers.plan.medium.name": {
|
||||
"message": "Medium"
|
||||
},
|
||||
"servers.plan.medium.symbol": {
|
||||
"message": "M"
|
||||
},
|
||||
"servers.plan.small.description": {
|
||||
"message": "Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding."
|
||||
},
|
||||
"servers.plan.small.name": {
|
||||
"message": "Small"
|
||||
},
|
||||
"servers.plan.small.symbol": {
|
||||
"message": "S"
|
||||
},
|
||||
"settings.billing.modal.cancel.action": {
|
||||
"message": "Cancel subscription"
|
||||
},
|
||||
|
||||
@@ -1346,7 +1346,7 @@ async function setProcessing() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -1389,7 +1389,7 @@ async function patchProject(resData, quiet = false) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
@@ -1426,7 +1426,7 @@ async function patchIcon(icon) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
|
||||
|
||||
@@ -621,6 +621,12 @@
|
||||
<h4>Version ID</h4>
|
||||
<CopyCode :text="version.id" />
|
||||
</div>
|
||||
<div v-if="!isEditing && flags.developerMode">
|
||||
<h4>Modrinth Maven</h4>
|
||||
<div class="maven-section">
|
||||
<CopyCode :text="`maven.modrinth:${project.id}:${version.id}`" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1136,7 +1142,7 @@ export default defineNuxtComponent({
|
||||
this.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
@@ -1545,6 +1551,12 @@ export default defineNuxtComponent({
|
||||
margin: 1rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.maven-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.team-member {
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
@@ -106,6 +106,13 @@
|
||||
},
|
||||
shown: currentMember || flags.developerMode,
|
||||
},
|
||||
{
|
||||
id: 'copy-maven',
|
||||
action: () => {
|
||||
copyToClipboard(`maven.modrinth:${project.slug}:${version.id}`);
|
||||
},
|
||||
shown: flags.developerMode,
|
||||
},
|
||||
{ divider: true, shown: currentMember },
|
||||
{
|
||||
id: 'edit',
|
||||
@@ -160,6 +167,10 @@
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy ID
|
||||
</template>
|
||||
<template #copy-maven>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy Modrinth Maven
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
@@ -277,7 +277,7 @@ const refunding = ref(false);
|
||||
const refundModal = ref();
|
||||
const selectedCharge = ref(null);
|
||||
const refundType = ref("full");
|
||||
const refundTypes = ref(["full", "partial"]);
|
||||
const refundTypes = ref(["full", "partial", "none"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(false);
|
||||
|
||||
|
||||
504
apps/frontend/src/pages/admin/servers/notices.vue
Normal file
504
apps/frontend/src/pages/admin/servers/notices.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<NewModal ref="createNoticeModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">{{
|
||||
editingNotice ? `Editing notice #${editingNotice?.id}` : "Creating a notice"
|
||||
}}</span>
|
||||
</template>
|
||||
<div class="flex w-[700px] flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label for="level-selector" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Level </span>
|
||||
<span>Determines how the notice should be styled.</span>
|
||||
</label>
|
||||
<TeleportDropdownMenu
|
||||
id="level-selector"
|
||||
v-model="newNoticeLevel"
|
||||
class="max-w-[10rem]"
|
||||
:options="levelOptions"
|
||||
:display-name="(x) => formatMessage(x.name)"
|
||||
name="Level"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
|
||||
<label for="notice-title" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Title </span>
|
||||
</label>
|
||||
<input
|
||||
id="notice-title"
|
||||
v-model="newNoticeTitle"
|
||||
placeholder="E.g. Maintenance"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="notice-message" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ newNoticeSurvey ? "Survey ID" : "Message" }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="newNoticeSurvey"
|
||||
id="notice-message"
|
||||
v-model="newNoticeMessage"
|
||||
placeholder="E.g. rXGtq2"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div v-else class="textarea-wrapper h-32">
|
||||
<textarea id="notice-message" v-model="newNoticeMessage" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!newNoticeSurvey" class="flex items-center justify-between gap-2">
|
||||
<label for="dismissable-toggle" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Dismissable </span>
|
||||
<span>Allow users to dismiss the notice from their panel.</span>
|
||||
</label>
|
||||
<Toggle id="dismissable-toggle" v-model="newNoticeDismissable" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label for="scheduled-date" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Announcement date </span>
|
||||
<span>Leave blank for notice to be available immediately.</span>
|
||||
</label>
|
||||
<input
|
||||
id="scheduled-date"
|
||||
v-model="newNoticeScheduledDate"
|
||||
type="datetime-local"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label for="expiration-date" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Expiration date </span>
|
||||
<span>The notice will automatically be deleted after this date.</span>
|
||||
</label>
|
||||
<input
|
||||
id="expiration-date"
|
||||
v-model="newNoticeExpiresDate"
|
||||
type="datetime-local"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-semibold text-contrast"> Preview </span>
|
||||
<ServerNotice
|
||||
:level="newNoticeLevel.id"
|
||||
:message="
|
||||
!trimmedMessage || trimmedMessage.length < 1
|
||||
? 'Type a message to begin previewing it.'
|
||||
: trimmedMessage
|
||||
"
|
||||
:dismissable="newNoticeDismissable"
|
||||
:title="trimmedTitle"
|
||||
preview
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-if="editingNotice" :disabled="!!noticeSubmitError" @click="() => saveChanges()">
|
||||
<SaveIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
<button v-else :disabled="!!noticeSubmitError" @click="() => createNotice()">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createNotice) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="createNoticeModal?.hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<AssignNoticeModal ref="assignNoticeModal" @close="refreshNotices" />
|
||||
<div class="page experimental-styles-within">
|
||||
<div
|
||||
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
|
||||
>
|
||||
<h1 class="m-0 text-2xl">Servers notices</h1>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openNewNoticeModal">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.createNotice) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="!notices || notices.length === 0">{{ formatMessage(messages.noNotices) }}</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-[auto_auto_auto] gap-4 md:grid-cols-[min-content_auto_auto_auto_auto_min-content]"
|
||||
>
|
||||
<div class="col-span-full grid grid-cols-subgrid gap-4 px-4 font-bold text-contrast">
|
||||
<div>{{ formatMessage(messages.id) }}</div>
|
||||
<div>{{ formatMessage(messages.begins) }}</div>
|
||||
<div>{{ formatMessage(messages.expires) }}</div>
|
||||
<div class="hidden md:block">{{ formatMessage(messages.level) }}</div>
|
||||
<div class="hidden md:block">{{ formatMessage(messages.dismissable) }}</div>
|
||||
<div class="hidden md:block">{{ formatMessage(messages.actions) }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="notice in notices"
|
||||
:key="`notice-${notice.id}`"
|
||||
class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4"
|
||||
>
|
||||
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
|
||||
<div>
|
||||
<CopyCode :text="`${notice.id}`" />
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span v-if="notice.announce_at">
|
||||
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
|
||||
dayjs(notice.announce_at).fromNow()
|
||||
}})
|
||||
</span>
|
||||
<template v-else> Never begins </template>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span
|
||||
v-if="notice.expires"
|
||||
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
{{ dayjs(notice.expires).fromNow() }}
|
||||
</span>
|
||||
<template v-else> Never expires </template>
|
||||
</div>
|
||||
<div
|
||||
:style="
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? {
|
||||
'--_color': NOTICE_LEVELS[notice.level].colors.text,
|
||||
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<TagItem>
|
||||
{{
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? formatMessage(NOTICE_LEVELS[notice.level].name)
|
||||
: notice.level
|
||||
}}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
'--_color': notice.dismissable ? 'var(--color-green)' : 'var(--color-red)',
|
||||
'--_bg-color': notice.dismissable ? 'var(--color-green-bg)' : 'var(--color-red-bg)',
|
||||
}"
|
||||
>
|
||||
<TagItem>
|
||||
{{
|
||||
formatMessage(notice.dismissable ? messages.dismissable : messages.undismissable)
|
||||
}}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="col-span-2 flex gap-2 md:col-span-1">
|
||||
<ButtonStyled>
|
||||
<button @click="() => startEditing(notice)">
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="() => deleteNotice(notice)">
|
||||
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full grid">
|
||||
<ServerNotice
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
preview
|
||||
/>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span v-if="!notice.assigned || notice.assigned.length === 0"
|
||||
>Not assigned to any servers</span
|
||||
>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
|
||||
</span>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers
|
||||
</span>
|
||||
<span v-else>
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers and
|
||||
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
|
||||
</span>
|
||||
•
|
||||
<button
|
||||
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
|
||||
@click="() => startEditing(notice, true)"
|
||||
>
|
||||
<SettingsIcon />
|
||||
Edit assignments
|
||||
</button>
|
||||
<template v-if="notice.dismissed_by.length > 0">
|
||||
•
|
||||
<span> Dismissed by {{ notice.dismissed_by.length }} servers </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CopyCode,
|
||||
TagItem,
|
||||
ButtonStyled,
|
||||
ServerNotice,
|
||||
commonMessages,
|
||||
NewModal,
|
||||
TeleportDropdownMenu,
|
||||
Toggle,
|
||||
} from "@modrinth/ui";
|
||||
import { SettingsIcon, PlusIcon, SaveIcon, TrashIcon, EditIcon, XIcon } from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { useVIntl } from "@vintl/vintl";
|
||||
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import { computed } from "vue";
|
||||
import { NOTICE_LEVELS } from "@modrinth/ui/src/utils/notices.ts";
|
||||
import { usePyroFetch } from "~/composables/pyroFetch.ts";
|
||||
import AssignNoticeModal from "~/components/ui/servers/notice/AssignNoticeModal.vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const app = useNuxtApp() as unknown as { $notify: any };
|
||||
|
||||
const notices = ref<ServerNoticeType[]>([]);
|
||||
const createNoticeModal = ref<InstanceType<typeof NewModal>>();
|
||||
const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>();
|
||||
|
||||
await refreshNotices();
|
||||
|
||||
async function refreshNotices() {
|
||||
await usePyroFetch("notices").then((res) => {
|
||||
notices.value = res as ServerNoticeType[];
|
||||
notices.value.sort((a, b) => {
|
||||
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at));
|
||||
if (dateDiff === 0) {
|
||||
return b.id - a.id;
|
||||
}
|
||||
|
||||
return dateDiff;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const levelOptions = Object.keys(NOTICE_LEVELS).map((x) => ({
|
||||
id: x,
|
||||
...NOTICE_LEVELS[x],
|
||||
}));
|
||||
|
||||
const DATE_TIME_FORMAT = "YYYY-MM-DDTHH:mm";
|
||||
|
||||
const newNoticeLevel = ref(levelOptions[0]);
|
||||
const newNoticeDismissable = ref(false);
|
||||
const newNoticeMessage = ref("");
|
||||
const newNoticeScheduledDate = ref<string>();
|
||||
const newNoticeTitle = ref<string>();
|
||||
const newNoticeExpiresDate = ref<string>();
|
||||
|
||||
function openNewNoticeModal() {
|
||||
newNoticeLevel.value = levelOptions[0];
|
||||
newNoticeDismissable.value = false;
|
||||
newNoticeMessage.value = "";
|
||||
newNoticeScheduledDate.value = undefined;
|
||||
newNoticeExpiresDate.value = undefined;
|
||||
editingNotice.value = undefined;
|
||||
createNoticeModal.value?.show();
|
||||
}
|
||||
|
||||
const editingNotice = ref<undefined | ServerNoticeType>();
|
||||
|
||||
function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
|
||||
newNoticeLevel.value = levelOptions.find((x) => x.id === notice.level) ?? levelOptions[0];
|
||||
newNoticeDismissable.value = notice.dismissable;
|
||||
newNoticeMessage.value = notice.message;
|
||||
newNoticeTitle.value = notice.title;
|
||||
newNoticeScheduledDate.value = dayjs(notice.announce_at).format(DATE_TIME_FORMAT);
|
||||
newNoticeExpiresDate.value = notice.expires
|
||||
? dayjs(notice.expires).format(DATE_TIME_FORMAT)
|
||||
: undefined;
|
||||
editingNotice.value = notice;
|
||||
if (assignments) {
|
||||
assignNoticeModal.value?.show?.(notice);
|
||||
} else {
|
||||
createNoticeModal.value?.show();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNotice(notice: ServerNoticeType) {
|
||||
await usePyroFetch(`notices/${notice.id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(() => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: `Successfully deleted notice #${notice.id}`,
|
||||
type: "success",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error deleting notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
await refreshNotices();
|
||||
}
|
||||
|
||||
const trimmedMessage = computed(() => newNoticeMessage.value?.trim());
|
||||
const trimmedTitle = computed(() => newNoticeTitle.value?.trim());
|
||||
const newNoticeSurvey = computed(() => newNoticeLevel.value.id === "survey");
|
||||
|
||||
const noticeSubmitError = computed(() => {
|
||||
let error: undefined | string;
|
||||
if (!trimmedMessage.value || trimmedMessage.value.length === 0) {
|
||||
error = "Notice message is required";
|
||||
}
|
||||
if (!newNoticeLevel.value) {
|
||||
error = "Notice level is required";
|
||||
}
|
||||
return error;
|
||||
});
|
||||
|
||||
function validateSubmission(message: string) {
|
||||
if (noticeSubmitError.value) {
|
||||
addNotification({
|
||||
group: "main",
|
||||
title: message,
|
||||
text: noticeSubmitError.value,
|
||||
type: "error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!validateSubmission("Error saving notice")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await usePyroFetch(`notices/${editingNotice.value?.id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
message: newNoticeMessage.value,
|
||||
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
|
||||
level: newNoticeLevel.value.id,
|
||||
dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
|
||||
announce_at: newNoticeScheduledDate.value
|
||||
? dayjs(newNoticeScheduledDate.value).toISOString()
|
||||
: dayjs().toISOString(),
|
||||
expires: newNoticeExpiresDate.value
|
||||
? dayjs(newNoticeExpiresDate.value).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error saving changes to notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
await refreshNotices();
|
||||
createNoticeModal.value?.hide();
|
||||
}
|
||||
|
||||
async function createNotice() {
|
||||
if (!validateSubmission("Error creating notice")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await usePyroFetch("notices", {
|
||||
method: "POST",
|
||||
body: {
|
||||
message: newNoticeMessage.value,
|
||||
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
|
||||
level: newNoticeLevel.value.id,
|
||||
dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
|
||||
announce_at: newNoticeScheduledDate.value
|
||||
? dayjs(newNoticeScheduledDate.value).toISOString()
|
||||
: dayjs().toISOString(),
|
||||
expires: newNoticeExpiresDate.value
|
||||
? dayjs(newNoticeExpiresDate.value).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error creating notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
await refreshNotices();
|
||||
createNoticeModal.value?.hide();
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
createNotice: {
|
||||
id: "servers.notices.create-notice",
|
||||
defaultMessage: "Create notice",
|
||||
},
|
||||
noNotices: {
|
||||
id: "servers.notices.no-notices",
|
||||
defaultMessage: "No notices",
|
||||
},
|
||||
dismissable: {
|
||||
id: "servers.notice.dismissable",
|
||||
defaultMessage: "Dismissable",
|
||||
},
|
||||
undismissable: {
|
||||
id: "servers.notice.undismissable",
|
||||
defaultMessage: "Undismissable",
|
||||
},
|
||||
id: {
|
||||
id: "servers.notice.id",
|
||||
defaultMessage: "ID",
|
||||
},
|
||||
begins: {
|
||||
id: "servers.notice.begins",
|
||||
defaultMessage: "Begins",
|
||||
},
|
||||
expires: {
|
||||
id: "servers.notice.expires",
|
||||
defaultMessage: "Expires",
|
||||
},
|
||||
actions: {
|
||||
id: "servers.notice.actions",
|
||||
defaultMessage: "Actions",
|
||||
},
|
||||
level: {
|
||||
id: "servers.notice.level",
|
||||
defaultMessage: "Level",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
padding: 1rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 78.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -697,7 +697,7 @@ async function deleteCollection() {
|
||||
addNotification({
|
||||
group: "main",
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ async function updateVenmo() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ async function cancelPayout(id) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ async function withdraw() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
<h2 class="m-0 text-lg font-bold">Experience modern, reliable hosting</h2>
|
||||
<h3 class="m-0 text-base font-normal text-secondary">
|
||||
Modrinth Servers are hosted on
|
||||
<span class="text-contrast">2023 Ryzen 7/9 CPUs with DDR5 RAM</span>, running on
|
||||
<span class="text-contrast">high-performance AMD CPUs with DDR5 RAM</span>, running on
|
||||
custom-built software to ensure your server performs smoothly.
|
||||
</h3>
|
||||
</div>
|
||||
@@ -329,27 +329,6 @@
|
||||
alt=""
|
||||
class="absolute -bottom-12 -right-[15%] hidden max-w-2xl rounded-2xl bg-brand p-4 lg:block"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="max-w-fit rounded-full bg-brand p-4 text-sm font-bold text-[var(--color-accent-contrast)] lg:absolute lg:bottom-8 lg:right-8 lg:block"
|
||||
>
|
||||
8.49 GB used
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex w-fit items-center gap-2 rounded-full bg-button-bg p-3 lg:hidden"
|
||||
>
|
||||
<SortAscendingIcon class="h-6 w-6" />
|
||||
Sort
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex w-fit items-center rounded-full bg-button-bg p-3 lg:hidden"
|
||||
>
|
||||
<SearchIcon class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid w-full grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
@@ -418,9 +397,24 @@
|
||||
</span>
|
||||
What kind of CPUs do Modrinth Servers run on?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Modrinth Servers use 2023 Ryzen 7 and Ryzen 9 CPUs at 4+ GHz, paired with DDR5
|
||||
memory.
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Modrinth Servers are powered by AMD Ryzen 7900 and 7950X3D equivalent CPUs at 5+
|
||||
GHz, paired with with DDR5 memory.
|
||||
</p>
|
||||
</details>
|
||||
<details pyro-hash="cpu-burst" class="group" :open="$route.hash === '#cpu-burst'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
</span>
|
||||
How do CPU burst threads work?
|
||||
</summary>
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
When your server is under heavy load, we temporarily give it access to additional
|
||||
CPU threads to help mitigate lag spikes and instability. This helps prevent the TPS
|
||||
from going below 20, ensuring the smoothest experience possible. Since those extra
|
||||
CPU threads are only shortly available during high load periods, they might not show
|
||||
up in Spark reports or other profiling tools.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -431,10 +425,12 @@
|
||||
</span>
|
||||
Do Modrinth Servers have DDoS protection?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Yes. All Modrinth Servers come with DDoS protection. Protection is powered by a
|
||||
combination of in-house network filtering as well as with our data center provider.
|
||||
Your server is safe on Modrinth.
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Yes. All Modrinth Servers come with DDoS protection powered by
|
||||
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank"
|
||||
>OVHcloud® Anti-DDoS infrastructure</a
|
||||
>
|
||||
which has over 17Tbps capacity. Your server is safe on Modrinth.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -445,11 +441,9 @@
|
||||
</span>
|
||||
Where are Modrinth Servers located? Can I choose a region?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Currently, Modrinth Servers are located throughout the United States in New York,
|
||||
Los Angelas, Dallas, Miami, and Spokane. More regions are coming soon! Your server's
|
||||
location is currently chosen algorithmically, but you will be able to choose a
|
||||
region in the future.
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Currently, Modrinth Servers are located on the east coast of the United States in
|
||||
Vint Hill, Virginia. More regions to come in the future!
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -460,7 +454,7 @@
|
||||
</span>
|
||||
Can I increase the storage on my server?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Yes, storage can be increased on your server at no additional cost. If you need more
|
||||
storage, reach out to Modrinth Support.
|
||||
</p>
|
||||
@@ -471,13 +465,19 @@
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
</span>
|
||||
How fast are Modrinth Servers? How many players can they handle?
|
||||
How fast are Modrinth Servers?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
During the Modrinth "Emergency SMP" test, we had over 80 players on a server running
|
||||
on the Large plan. The server ran smoothly and was only limited by RAM. We're
|
||||
confident that Modrinth Servers can handle a large number of players, with any kind
|
||||
of modpack.
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Modrinth Servers are hosted on very modern high-performance hardware, but it's tough
|
||||
to say how exactly that will translate into how fast your server will run because
|
||||
there are so many factors that affect it, such as the mods, data packs, or plugins
|
||||
you're running on your server, and even user behavior.
|
||||
</p>
|
||||
<p class="mb-0 ml-6 mt-3 leading-[160%]">
|
||||
Most performance issues that arise tend to be the fault of an unoptimized modpack,
|
||||
mod, data pack, or plugin that causes the server to lag. Since our servers are very
|
||||
high-end, you shouldn't run into much trouble as long as you pick an appropriate
|
||||
plan for the content you're running on the server.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -486,6 +486,7 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="false"
|
||||
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
|
||||
>
|
||||
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
|
||||
@@ -596,182 +597,49 @@
|
||||
</h2>
|
||||
|
||||
<ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isSmallLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.small?.available }} left in stock!
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||
:class="{ '!rounded-t-none': isSmallLowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="m-0">Small</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue"
|
||||
>
|
||||
S
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">
|
||||
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">4 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">4 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">32 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$12<span class="text-sm font-normal text-secondary">/month</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="blue" size="large">
|
||||
<a
|
||||
v-if="!loggedOut && isSmallAtCapacity"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-blue !font-medium !text-blue"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-blue !font-medium !text-blue"
|
||||
@click="selectProduct('small')"
|
||||
>
|
||||
Get Started
|
||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isMediumLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.medium?.available }} left in stock!
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
background: radial-gradient(
|
||||
86.12% 101.64% at 95.97% 94.07%,
|
||||
rgba(27, 217, 106, 0.23) 0%,
|
||||
rgba(14, 115, 56, 0.2) 100%
|
||||
);
|
||||
border: 1px solid rgba(12, 107, 52, 0.55);
|
||||
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
|
||||
"
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl p-8 text-left"
|
||||
:class="{ '!rounded-t-none': isMediumLowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="m-0">Medium</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand"
|
||||
>
|
||||
M
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">Great for modded multiplayer and small communities.</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">6 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">6 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">48 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$18<span class="text-sm font-normal text-secondary">/month</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<a
|
||||
v-if="!loggedOut && isMediumAtCapacity"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-green !font-medium !text-green"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-green !font-medium !text-green"
|
||||
@click="selectProduct('medium')"
|
||||
>
|
||||
Get Started
|
||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-green" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isLargeLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.large?.available }} left in stock!
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||
:class="{ '!rounded-t-none': isLargeLowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="m-0">Large</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple"
|
||||
>
|
||||
L
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">8 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">8 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">64 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$24<span class="text-sm font-normal text-secondary">/month</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<a
|
||||
v-if="!loggedOut && isLargeAtCapacity"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-purple !font-medium !text-purple"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-purple !font-medium !text-purple"
|
||||
@click="selectProduct('large')"
|
||||
>
|
||||
Get Started
|
||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
<ServerPlanSelector
|
||||
:capacity="capacityStatuses?.small?.available"
|
||||
plan="small"
|
||||
:ram="plans.small.metadata.ram"
|
||||
:storage="plans.small.metadata.storage"
|
||||
:cpus="plans.small.metadata.cpu"
|
||||
:price="
|
||||
plans.small?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
|
||||
?.monthly
|
||||
"
|
||||
@select="selectProduct('small')"
|
||||
@scroll-to-faq="scrollToFaq()"
|
||||
/>
|
||||
<ServerPlanSelector
|
||||
:capacity="capacityStatuses?.medium?.available"
|
||||
plan="medium"
|
||||
:ram="plans.medium.metadata.ram"
|
||||
:storage="plans.medium.metadata.storage"
|
||||
:cpus="plans.medium.metadata.cpu"
|
||||
:price="
|
||||
plans.medium?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
|
||||
?.monthly
|
||||
"
|
||||
@select="selectProduct('medium')"
|
||||
@scroll-to-faq="scrollToFaq()"
|
||||
/>
|
||||
<ServerPlanSelector
|
||||
:capacity="capacityStatuses?.large?.available"
|
||||
:ram="plans.large.metadata.ram"
|
||||
:storage="plans.large.metadata.storage"
|
||||
:cpus="plans.large.metadata.cpu"
|
||||
:price="
|
||||
plans.large?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
|
||||
?.monthly
|
||||
"
|
||||
plan="large"
|
||||
@select="selectProduct('large')"
|
||||
@scroll-to-faq="scrollToFaq()"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
class="mb-4 flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
|
||||
class="mb-24 flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="m-0">Build your own</h1>
|
||||
@@ -781,11 +649,13 @@
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col-reverse gap-2 md:w-auto md:flex-col md:items-center">
|
||||
<div
|
||||
class="experimental-styles-within flex w-full flex-col-reverse gap-2 md:w-auto md:flex-col md:items-center"
|
||||
>
|
||||
<ButtonStyled color="standard" size="large">
|
||||
<button class="w-full md:w-fit" @click="selectProduct('custom')">
|
||||
Build your own
|
||||
<RightArrowIcon class="!min-h-4 !min-w-4" />
|
||||
<RightArrowIcon class="shrink-0" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0 text-sm">Starting at $3/GB RAM</p>
|
||||
@@ -802,9 +672,6 @@ import {
|
||||
BoxIcon,
|
||||
GameIcon,
|
||||
RightArrowIcon,
|
||||
SearchIcon,
|
||||
SortAscendingIcon,
|
||||
ExternalIcon,
|
||||
TerminalSquareIcon,
|
||||
TransferIcon,
|
||||
VersionIcon,
|
||||
@@ -813,6 +680,7 @@ import {
|
||||
import { products } from "~/generated/state.json";
|
||||
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
|
||||
import Globe from "~/components/ui/servers/Globe.vue";
|
||||
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
|
||||
|
||||
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
|
||||
const pyroPlanProducts = pyroProducts.filter(
|
||||
@@ -893,6 +761,7 @@ async function fetchCapacityStatuses(customProduct = null) {
|
||||
swap_mb: product.metadata.swap,
|
||||
storage_mb: product.metadata.storage,
|
||||
},
|
||||
bypassAuth: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -931,21 +800,6 @@ const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.availa
|
||||
const isLargeAtCapacity = computed(() => capacityStatuses.value?.large?.available === 0);
|
||||
const isCustomAtCapacity = computed(() => capacityStatuses.value?.custom?.available === 0);
|
||||
|
||||
const isSmallLowStock = computed(() => {
|
||||
const available = capacityStatuses.value?.small?.available;
|
||||
return available !== undefined && available > 0 && available < 8;
|
||||
});
|
||||
|
||||
const isMediumLowStock = computed(() => {
|
||||
const available = capacityStatuses.value?.medium?.available;
|
||||
return available !== undefined && available > 0 && available < 8;
|
||||
});
|
||||
|
||||
const isLargeLowStock = computed(() => {
|
||||
const available = capacityStatuses.value?.large?.available;
|
||||
return available !== undefined && available > 0 && available < 8;
|
||||
});
|
||||
|
||||
const startTyping = () => {
|
||||
const currentWord = words[currentWordIndex.value];
|
||||
if (isDeleting.value) {
|
||||
|
||||
@@ -1,386 +1,391 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
|
||||
<div
|
||||
v-if="filteredNotices.length > 0"
|
||||
class="experimental-styles-within relative mx-auto flex w-full min-w-0 max-w-[1280px] flex-col gap-3 px-6"
|
||||
>
|
||||
<ServerNotice
|
||||
v-for="notice in filteredNotices"
|
||||
:key="`notice-${notice.id}`"
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
class="w-full"
|
||||
@dismiss="() => dismissNotice(notice.id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Your server's hardware is currently being upgraded and will be back online shortly!
|
||||
</p>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Your server's hardware is currently being upgraded and will be back online shortly!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
You recently contacted Modrinth Support, and we're actively working on your server. It
|
||||
will be back online shortly.
|
||||
</p>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
You recently contacted Modrinth Support, and we're actively working on your server. It
|
||||
will be back online shortly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<LockIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<LockIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
{{
|
||||
serverData.suspension_reason === "cancelled"
|
||||
? "Your subscription has been cancelled."
|
||||
: serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
}}
|
||||
<br />
|
||||
Contact Modrinth support if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
||||
<button class="mt-6 !w-full">Go to billing settings</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
server.general?.error?.error.statusCode === 403 ||
|
||||
server.general?.error?.error.statusCode === 404
|
||||
"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<TransferIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
You don't have permission to view this server or it no longer exists. If you believe this
|
||||
is an error, please contact Modrinth support.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
|
||||
<button class="mt-6 !w-full">Go back to all servers</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.general?.error?.error.statusCode === 503"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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-red p-4">
|
||||
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||
</div>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your server's node, where your Modrinth Server is physically hosted, is experiencing
|
||||
issues. We are working with our datacenter to resolve the issue as quickly as possible.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your data is safe and will not be lost, and your server will be back online as soon as the
|
||||
issue is resolved.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
|
||||
bubble in the bottom right corner and we'll be happy to help.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<UiCopyCode :text="'Server ID: ' + server.serverId" />
|
||||
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="
|
||||
() =>
|
||||
navigateTo('https://discord.modrinth.com', {
|
||||
external: true,
|
||||
})
|
||||
"
|
||||
>
|
||||
<button class="mt-6 !w-full">Join Modrinth Discord</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.general?.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<TransferIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1>
|
||||
<div class="text-center text-secondary">
|
||||
{{
|
||||
serverData.suspension_reason === "cancelled"
|
||||
? "Your subscription has been cancelled."
|
||||
: serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...`
|
||||
}}
|
||||
<br />
|
||||
Contact Modrinth support if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
||||
<button class="mt-6 !w-full">Go to billing settings</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
server.general?.error?.error.statusCode === 403 ||
|
||||
server.general?.error?.error.statusCode === 404
|
||||
"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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">
|
||||
<TransferIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
You don't have permission to view this server or it no longer exists. If you believe
|
||||
this is an error, please contact Modrinth support.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
<p class="text-lg text-secondary">
|
||||
Something went wrong, and we couldn't connect to your server. This is likely due to a
|
||||
temporary network issue. You'll be reconnected automatically.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="brand"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-6 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SERVER START -->
|
||||
<div
|
||||
v-else-if="serverData"
|
||||
data-pyro-server-manager-root
|
||||
class="experimental-styles-within mobile-blurred-servericon relative mx-auto mb-6 box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
|
||||
:style="{
|
||||
'--server-bg-image': serverData.image
|
||||
? `url(${serverData.image})`
|
||||
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
|
||||
}"
|
||||
>
|
||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
|
||||
<UiServersServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
||||
>
|
||||
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
|
||||
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
|
||||
<h1
|
||||
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
|
||||
>
|
||||
{{ serverData.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="isConnected"
|
||||
data-pyro-server-action-buttons
|
||||
class="server-action-buttons-anim flex w-fit flex-shrink-0"
|
||||
>
|
||||
<UiServersPanelServerActionButton
|
||||
class="flex-shrink-0"
|
||||
:is-online="isServerRunning"
|
||||
:is-actioning="isActioning"
|
||||
:is-installing="serverData.status === 'installing'"
|
||||
:disabled="isActioning || !!error"
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
|
||||
<button class="mt-6 !w-full">Go back to all servers</button>
|
||||
</ButtonStyled>
|
||||
<UiServersServerInfoLabels
|
||||
:server-data="serverData"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.general?.error?.error.statusCode === 503"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<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-red p-4">
|
||||
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||
</div>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your server's node, where your Modrinth Server is physically hosted, is experiencing
|
||||
issues. We are working with our datacenter to resolve the issue as quickly as possible.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your data is safe and will not be lost, and your server will be back online as soon as
|
||||
the issue is resolved.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
|
||||
bubble in the bottom right corner and we'll be happy to help.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<UiCopyCode :text="'Server ID: ' + server.serverId" />
|
||||
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="
|
||||
() =>
|
||||
navigateTo('https://discord.modrinth.com', {
|
||||
external: true,
|
||||
})
|
||||
"
|
||||
>
|
||||
<button class="mt-6 !w-full">Join Modrinth Discord</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.general?.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
data-pyro-navigation
|
||||
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
|
||||
>
|
||||
<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">
|
||||
<TransferIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1>
|
||||
<div class="text-center text-secondary">
|
||||
{{
|
||||
formattedTime == "00"
|
||||
? "Reconnecting..."
|
||||
: `Retrying in ${formattedTime} seconds...`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Something went wrong, and we couldn't connect to your server. This is likely due to a
|
||||
temporary network issue. You'll be reconnected automatically.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="brand"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-6 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<UiNavTabs :links="navLinks" />
|
||||
</div>
|
||||
<!-- SERVER START -->
|
||||
<div
|
||||
v-else-if="serverData"
|
||||
data-pyro-server-manager-root
|
||||
class="experimental-styles-within mobile-blurred-servericon relative mx-auto box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
|
||||
:style="{
|
||||
'--server-bg-image': serverData.image
|
||||
? `url(${serverData.image})`
|
||||
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
|
||||
}"
|
||||
>
|
||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
|
||||
<UiServersServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
||||
>
|
||||
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
|
||||
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
|
||||
<h1
|
||||
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
|
||||
>
|
||||
{{ serverData.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="isConnected"
|
||||
data-pyro-server-action-buttons
|
||||
class="server-action-buttons-anim flex w-fit flex-shrink-0"
|
||||
>
|
||||
<UiServersPanelServerActionButton
|
||||
class="flex-shrink-0"
|
||||
:is-online="isServerRunning"
|
||||
:is-actioning="isActioning"
|
||||
:is-installing="serverData.status === 'installing'"
|
||||
:disabled="isActioning || !!error"
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiServersServerInfoLabels
|
||||
:server-data="serverData"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
<div data-pyro-mount class="h-full w-full flex-1">
|
||||
<div
|
||||
v-if="error"
|
||||
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
|
||||
<div class="flex flex-col gap-2 leading-[150%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
|
||||
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorTitle.toLocaleLowerCase() === 'installation error'" class="font-normal">
|
||||
<div
|
||||
v-if="errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'"
|
||||
>
|
||||
An invalid loader or Minecraft version was specified and could not be installed.
|
||||
<ul class="m-0 mt-4 p-0 pl-4">
|
||||
<li>
|
||||
If this version of Minecraft was released recently, please check if Modrinth
|
||||
Servers supports it.
|
||||
</li>
|
||||
<li>
|
||||
If you've installed a modpack, it may have been packaged incorrectly or may not
|
||||
be compatible with the loader.
|
||||
</li>
|
||||
<li>
|
||||
Your server may need to be reinstalled with a valid mod loader and version. You
|
||||
can change the loader by clicking the "Change Loader" button.
|
||||
</li>
|
||||
<li>
|
||||
If you're stuck, please contact Modrinth support with the information below:
|
||||
</li>
|
||||
</ul>
|
||||
<ButtonStyled>
|
||||
<button class="mt-2" @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
|
||||
An internal error occurred while installing your server. Don't fret — try
|
||||
reinstalling your server, and if the problem persists, please contact Modrinth
|
||||
support with your server's debug information.
|
||||
</div>
|
||||
<div v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'">
|
||||
An error occurred while installing your server because Modrinth Servers does not
|
||||
support the version of Minecraft or the loader you specified. Try reinstalling your
|
||||
server with a different version or loader, and if the problem persists, please
|
||||
contact Modrinth support with your server's debug information.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle === 'Installation error'"
|
||||
class="mt-2 flex flex-col gap-4 sm:flex-row"
|
||||
>
|
||||
<ButtonStyled v-if="errorLog">
|
||||
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" type="standard">
|
||||
<NuxtLink
|
||||
class="whitespace-pre"
|
||||
:to="`/servers/manage/${serverId}/options/loader`"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Change Loader
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-pyro-navigation
|
||||
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
|
||||
v-if="!isConnected && !isReconnecting && !isLoading"
|
||||
data-pyro-server-ws-error
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
|
||||
>
|
||||
<UiNavTabs :links="navLinks" />
|
||||
<IssuesIcon class="size-5 text-red" />
|
||||
Something went wrong...
|
||||
</div>
|
||||
|
||||
<div data-pyro-mount class="h-full w-full flex-1">
|
||||
<div
|
||||
v-if="error"
|
||||
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
|
||||
<div class="flex flex-col gap-2 leading-[150%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
|
||||
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
|
||||
class="font-normal"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
|
||||
"
|
||||
>
|
||||
An invalid loader or Minecraft version was specified and could not be installed.
|
||||
<ul class="m-0 mt-4 p-0 pl-4">
|
||||
<li>
|
||||
If this version of Minecraft was released recently, please check if Modrinth
|
||||
Servers supports it.
|
||||
</li>
|
||||
<li>
|
||||
If you've installed a modpack, it may have been packaged incorrectly or may
|
||||
not be compatible with the loader.
|
||||
</li>
|
||||
<li>
|
||||
Your server may need to be reinstalled with a valid mod loader and version.
|
||||
You can change the loader by clicking the "Change Loader" button.
|
||||
</li>
|
||||
<li>
|
||||
If you're stuck, please contact Modrinth support with the information below:
|
||||
</li>
|
||||
</ul>
|
||||
<ButtonStyled>
|
||||
<button class="mt-2" @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
|
||||
An internal error occurred while installing your server. Don't fret — try
|
||||
reinstalling your server, and if the problem persists, please contact Modrinth
|
||||
support with your server's debug information.
|
||||
</div>
|
||||
<div
|
||||
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
|
||||
>
|
||||
An error occurred while installing your server because Modrinth Servers does not
|
||||
support the version of Minecraft or the loader you specified. Try reinstalling
|
||||
your server with a different version or loader, and if the problem persists,
|
||||
please contact Modrinth support with your server's debug information.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle === 'Installation error'"
|
||||
class="mt-2 flex flex-col gap-4 sm:flex-row"
|
||||
>
|
||||
<ButtonStyled v-if="errorLog">
|
||||
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" type="standard">
|
||||
<NuxtLink
|
||||
class="whitespace-pre"
|
||||
:to="`/servers/manage/${serverId}/options/loader`"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Change Loader
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isConnected && !isReconnecting && !isLoading"
|
||||
data-pyro-server-ws-error
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
|
||||
>
|
||||
<IssuesIcon class="size-5 text-red" />
|
||||
Something went wrong...
|
||||
</div>
|
||||
|
||||
<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-sm text-contrast"
|
||||
>
|
||||
<UiServersPanelSpinner />
|
||||
Hang on, we're reconnecting to your server.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverData.status === 'installing'"
|
||||
data-pyro-server-installing
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
|
||||
>
|
||||
<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
|
||||
:route="route"
|
||||
:is-connected="isConnected"
|
||||
:is-ws-auth-incorrect="isWSAuthIncorrect"
|
||||
:is-server-running="isServerRunning"
|
||||
:stats="stats"
|
||||
:server-power-state="serverPowerState"
|
||||
:power-state-details="powerStateDetails"
|
||||
:socket="socket"
|
||||
:server="server"
|
||||
@reinstall="onReinstall"
|
||||
/>
|
||||
<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-sm text-contrast"
|
||||
>
|
||||
<UiServersPanelSpinner />
|
||||
Hang on, we're reconnecting to your server.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverData.status === 'installing'"
|
||||
data-pyro-server-installing
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
|
||||
>
|
||||
<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
|
||||
:route="route"
|
||||
:is-connected="isConnected"
|
||||
:is-ws-auth-incorrect="isWSAuthIncorrect"
|
||||
:is-server-running="isServerRunning"
|
||||
:stats="stats"
|
||||
:server-power-state="serverPowerState"
|
||||
:power-state-details="powerStateDetails"
|
||||
:socket="socket"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@reinstall="onReinstall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -398,11 +403,16 @@ import {
|
||||
LockIcon,
|
||||
} from "@modrinth/assets";
|
||||
import DOMPurify from "dompurify";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ButtonStyled, ServerNotice } from "@modrinth/ui";
|
||||
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
||||
import { reloadNuxtApp, navigateTo } from "#app";
|
||||
import type { MessageDescriptor } from "@vintl/vintl";
|
||||
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
import { type Backup } from "~/composables/pyroServers.ts";
|
||||
import { usePyroFetch } from "~/composables/pyroFetch.ts";
|
||||
|
||||
const app = useNuxtApp() as unknown as { $notify: any };
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isReconnecting = ref(false);
|
||||
@@ -412,15 +422,13 @@ const isFirstMount = ref(true);
|
||||
const isMounted = ref(true);
|
||||
|
||||
const INTERCOM_APP_ID = ref("ykeritl9");
|
||||
const auth = await useAuth();
|
||||
// @ts-expect-error - Auth is untyped
|
||||
const auth = (await useAuth()) as unknown as {
|
||||
value: { user: { id: string; username: string; email: string; created: string } };
|
||||
};
|
||||
const userId = ref(auth.value?.user?.id ?? null);
|
||||
// @ts-expect-error - Auth is untyped
|
||||
const username = ref(auth.value?.user?.username ?? null);
|
||||
// @ts-expect-error - Auth is untyped
|
||||
const email = ref(auth.value?.user?.email ?? null);
|
||||
const createdAt = ref(
|
||||
// @ts-expect-error - Auth is untyped
|
||||
auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null,
|
||||
);
|
||||
|
||||
@@ -526,6 +534,99 @@ const navLinks = [
|
||||
},
|
||||
];
|
||||
|
||||
const filteredNotices = computed(
|
||||
() => serverData.value?.notices?.filter((n) => n.level !== "survey") ?? [],
|
||||
);
|
||||
const surveyNotice = computed(() => serverData.value?.notices?.find((n) => n.level === "survey"));
|
||||
|
||||
async function dismissSurvey() {
|
||||
const noticeId = surveyNotice.value?.id;
|
||||
if (noticeId === undefined) {
|
||||
console.warn("No survey notice to dismiss");
|
||||
return;
|
||||
}
|
||||
await dismissNotice(noticeId);
|
||||
console.log(`Dismissed survey notice ${noticeId}`);
|
||||
}
|
||||
|
||||
type TallyPopupOptions = {
|
||||
key?: string;
|
||||
layout?: "default" | "modal";
|
||||
width?: number;
|
||||
alignLeft?: boolean;
|
||||
hideTitle?: boolean;
|
||||
overlay?: boolean;
|
||||
emoji?: {
|
||||
text: string;
|
||||
animation:
|
||||
| "none"
|
||||
| "wave"
|
||||
| "tada"
|
||||
| "heart-beat"
|
||||
| "spin"
|
||||
| "flash"
|
||||
| "bounce"
|
||||
| "rubber-band"
|
||||
| "head-shake";
|
||||
};
|
||||
autoClose?: number;
|
||||
showOnce?: boolean;
|
||||
doNotShowAfterSubmit?: boolean;
|
||||
customFormUrl?: string;
|
||||
hiddenFields?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onPageView?: (page: number) => void;
|
||||
onSubmit?: (payload: unknown) => void;
|
||||
};
|
||||
|
||||
const popupOptions = computed(
|
||||
() =>
|
||||
({
|
||||
layout: "default",
|
||||
width: 400,
|
||||
autoClose: 2000,
|
||||
hideTitle: true,
|
||||
hiddenFields: {
|
||||
username: auth.value?.user?.username,
|
||||
user_id: auth.value?.user?.id,
|
||||
user_email: auth.value?.user?.email,
|
||||
server_id: serverData.value?.server_id,
|
||||
loader: serverData.value?.loader,
|
||||
game_version: serverData.value?.mc_version,
|
||||
modpack_id: serverData.value?.project?.id,
|
||||
modpack_name: serverData.value?.project?.title,
|
||||
},
|
||||
onOpen: () => console.log(`Opened survey notice: ${surveyNotice.value?.id}`),
|
||||
onClose: async () => await dismissSurvey(),
|
||||
onSubmit: (payload: any) => {
|
||||
console.log("Form submitted:", payload);
|
||||
},
|
||||
}) satisfies TallyPopupOptions,
|
||||
);
|
||||
|
||||
function showSurvey() {
|
||||
if (!surveyNotice.value) {
|
||||
console.warn("No survey notice to open");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ((window as any).Tally?.openPopup) {
|
||||
console.log(
|
||||
`Opening Tally popup for survey notice ${surveyNotice.value?.id} (form ID: ${surveyNotice.value?.message})`,
|
||||
);
|
||||
(window as any).Tally.openPopup(surveyNotice.value?.message, popupOptions.value);
|
||||
} else {
|
||||
console.warn("Tally script not yet loaded");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error opening Tally popup:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (!isMounted.value) return;
|
||||
|
||||
@@ -661,6 +762,31 @@ const handleWebSocketMessage = (data: WSEvent) => {
|
||||
uptimeSeconds.value = data.uptime;
|
||||
startUptimeUpdates();
|
||||
break;
|
||||
case "backup-progress": {
|
||||
// Update a backup's state
|
||||
const curBackup = server.backups?.data.find((backup) => backup.id === data.id);
|
||||
|
||||
if (!curBackup) {
|
||||
console.log(`Ignoring backup-progress event for unknown backup: ${data.id}`);
|
||||
} else {
|
||||
console.log(
|
||||
`Handling backup progress for ${curBackup.name} (${data.id}) task: ${data.task} state: ${data.state} progress: ${data.progress}`,
|
||||
);
|
||||
|
||||
if (!curBackup.task) {
|
||||
curBackup.task = {};
|
||||
}
|
||||
|
||||
curBackup.task[data.task] = {
|
||||
progress: data.progress,
|
||||
state: data.state,
|
||||
};
|
||||
|
||||
curBackup.ongoing = data.task === "create" && data.state === "ongoing";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn("Unhandled WebSocket event:", data);
|
||||
}
|
||||
@@ -859,6 +985,41 @@ const formattedTime = computed(() => {
|
||||
return `${seconds.toString().padStart(2, "0")}`;
|
||||
});
|
||||
|
||||
export type BackupInProgressReason = {
|
||||
type: string;
|
||||
tooltip: MessageDescriptor;
|
||||
};
|
||||
|
||||
const RestoreInProgressReason = {
|
||||
type: "restore",
|
||||
tooltip: defineMessage({
|
||||
id: "servers.backup.restore.in-progress.tooltip",
|
||||
defaultMessage: "Backup restore in progress",
|
||||
}),
|
||||
} satisfies BackupInProgressReason;
|
||||
|
||||
const CreateInProgressReason = {
|
||||
type: "create",
|
||||
tooltip: defineMessage({
|
||||
id: "servers.backup.create.in-progress.tooltip",
|
||||
defaultMessage: "Backup creation in progress",
|
||||
}),
|
||||
} satisfies BackupInProgressReason;
|
||||
|
||||
const backupInProgress = computed(() => {
|
||||
const backups = server.backups?.data;
|
||||
if (!backups) {
|
||||
return undefined;
|
||||
}
|
||||
if (backups.find((backup: Backup) => backup?.task?.create?.state === "ongoing")) {
|
||||
return CreateInProgressReason;
|
||||
}
|
||||
if (backups.find((backup: Backup) => backup?.task?.restore?.state === "ongoing")) {
|
||||
return RestoreInProgressReason;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const stopPolling = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
@@ -927,6 +1088,20 @@ const cleanup = () => {
|
||||
DOMPurify.removeHook("afterSanitizeAttributes");
|
||||
};
|
||||
|
||||
async function dismissNotice(noticeId: number) {
|
||||
await usePyroFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
|
||||
method: "POST",
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error dismissing notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
await server.refresh(["general"]);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
if (server.general?.status === "suspended") {
|
||||
@@ -974,6 +1149,10 @@ onMounted(() => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (surveyNotice.value) {
|
||||
showSurvey();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -998,6 +1177,15 @@ watch(
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
src: "https://tally.so/widgets/embed.js",
|
||||
defer: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,248 +1,147 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div
|
||||
v-if="server.backups?.error"
|
||||
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
|
||||
v-if="server.backups?.error"
|
||||
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>
|
||||
<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.backups.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
<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.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 v-else-if="data" class="contents">
|
||||
<LazyUiServersBackupCreateModal
|
||||
ref="createBackupModal"
|
||||
:server="server"
|
||||
@backup-created="handleBackupCreated"
|
||||
/>
|
||||
<LazyUiServersBackupRenameModal
|
||||
ref="renameBackupModal"
|
||||
:server="server"
|
||||
:current-backup-id="currentBackup"
|
||||
:backup-name="renameBackupName"
|
||||
@backup-renamed="handleBackupRenamed"
|
||||
/>
|
||||
<LazyUiServersBackupRestoreModal
|
||||
ref="restoreBackupModal"
|
||||
:server="server"
|
||||
:backup-id="currentBackup"
|
||||
:backup-name="currentBackupDetails?.name ?? ''"
|
||||
:backup-created-at="currentBackupDetails?.created_at ?? ''"
|
||||
@backup-restored="handleBackupRestored"
|
||||
/>
|
||||
<LazyUiServersBackupDeleteModal
|
||||
ref="deleteBackupModal"
|
||||
:server="server"
|
||||
:backup-id="currentBackup"
|
||||
:backup-name="currentBackupDetails?.name ?? ''"
|
||||
:backup-created-at="currentBackupDetails?.created_at ?? ''"
|
||||
@backup-deleted="handleBackupDeleted"
|
||||
/>
|
||||
</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" />
|
||||
|
||||
<LazyUiServersBackupSettingsModal ref="backupSettingsModal" :server="server" />
|
||||
|
||||
<ul class="m-0 flex list-none flex-col gap-4 p-0">
|
||||
<div class="relative w-full overflow-hidden rounded-2xl bg-bg-raised p-6 shadow-md">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row sm:gap-0">
|
||||
<div class="flex flex-col items-baseline gap-2">
|
||||
<div class="text-2xl font-bold text-contrast">
|
||||
{{
|
||||
data.used_backup_quota === 0
|
||||
? "No backups"
|
||||
: `You've created ${data.used_backup_quota} backup${data.used_backup_quota === 1 ? "" : "s"}`
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
data.backup_quota - data.used_backup_quota === 0
|
||||
? "You have reached your backup limit. Consider removing old backups to create new ones."
|
||||
: `You can create ${data.backup_quota - data.used_backup_quota} more backups for your server.`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled type="standard">
|
||||
<button
|
||||
:disabled="server.general?.status === 'installing'"
|
||||
@click="showbackupSettingsModal"
|
||||
>
|
||||
<SettingsIcon class="h-5 w-5" />
|
||||
Auto backups
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button
|
||||
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) ||
|
||||
server.general?.status === 'installing'
|
||||
"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="backups.some((backup) => backup.ongoing)"
|
||||
data-pyro-server-backup-ongoing
|
||||
class="flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
|
||||
>
|
||||
A backup is currently being created. This may take a few minutes. This page will
|
||||
automatically refresh when the backup is complete.
|
||||
</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="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,
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</li>
|
||||
{{ data.used_backup_quota }} / {{ data.backup_quota }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<p class="m-0">
|
||||
You can have up to {{ data.backup_quota }} backups at once, securely off-site with
|
||||
Backblaze.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
||||
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
|
||||
>
|
||||
<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"
|
||||
<ButtonStyled type="standard">
|
||||
<button
|
||||
v-tooltip="
|
||||
'Auto backups are currently unavailable; we apologize for the inconvenience.'
|
||||
"
|
||||
:disabled="true || server.general?.status === 'installing'"
|
||||
@click="showbackupSettingsModal"
|
||||
>
|
||||
<DownloadIcon class="h-20 w-20 text-contrast" />
|
||||
</div>
|
||||
<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="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>
|
||||
@@ -250,25 +149,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ButtonStyled, TagItem } from "@modrinth/ui";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import {
|
||||
PlusIcon,
|
||||
CheckIcon,
|
||||
CalendarIcon,
|
||||
MoreHorizontalIcon,
|
||||
EditIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
TrashIcon,
|
||||
SettingsIcon,
|
||||
BoxIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { SpinnerIcon, PlusIcon, DownloadIcon, SettingsIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import BackupItem from "~/components/ui/servers/BackupItem.vue";
|
||||
import BackupRenameModal from "~/components/ui/servers/BackupRenameModal.vue";
|
||||
import BackupCreateModal from "~/components/ui/servers/BackupCreateModal.vue";
|
||||
import BackupRestoreModal from "~/components/ui/servers/BackupRestoreModal.vue";
|
||||
import BackupDeleteModal from "~/components/ui/servers/BackupDeleteModal.vue";
|
||||
import BackupSettingsModal from "~/components/ui/servers/BackupSettingsModal.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
@@ -299,19 +190,30 @@ useHead({
|
||||
|
||||
const overTheTopDownloadAnimation = ref();
|
||||
|
||||
const createBackupModal = ref<typeof NewModal>();
|
||||
const renameBackupModal = ref<typeof NewModal>();
|
||||
const restoreBackupModal = ref<typeof NewModal>();
|
||||
const deleteBackupModal = ref<typeof NewModal>();
|
||||
const backupSettingsModal = ref<typeof NewModal>();
|
||||
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 renameBackupName = ref("");
|
||||
const currentBackup = ref("");
|
||||
|
||||
const refreshInterval = ref<ReturnType<typeof setInterval>>();
|
||||
|
||||
const currentBackupDetails = computed(() => {
|
||||
return backups.value.find((backup) => backup.id === currentBackup.value);
|
||||
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;
|
||||
});
|
||||
|
||||
const showCreateModel = () => {
|
||||
@@ -322,69 +224,17 @@ const showbackupSettingsModal = () => {
|
||||
backupSettingsModal.value?.show();
|
||||
};
|
||||
|
||||
const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
function triggerDownloadAnimation() {
|
||||
overTheTopDownloadAnimation.value = true;
|
||||
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500);
|
||||
}
|
||||
|
||||
const initiateDownload = async (backupId: string) => {
|
||||
triggerDownloadAnimation();
|
||||
|
||||
const prepareDownload = async (backupId: string) => {
|
||||
try {
|
||||
const downloadurl: any = await props.server.backups?.download(backupId);
|
||||
if (!downloadurl || !downloadurl.download_url) {
|
||||
throw new Error("Invalid download URL.");
|
||||
}
|
||||
|
||||
let finalDownloadUrl: string = downloadurl.download_url;
|
||||
|
||||
if (!/^https?:\/\//i.test(finalDownloadUrl)) {
|
||||
finalDownloadUrl = `https://${finalDownloadUrl.startsWith("/") ? finalDownloadUrl.substring(1) : finalDownloadUrl}`;
|
||||
}
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = finalDownloadUrl;
|
||||
a.setAttribute("download", "");
|
||||
a.click();
|
||||
a.remove();
|
||||
await props.server.backups?.prepare(backupId);
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
console.error("Failed to prepare download:", error);
|
||||
addNotification({ type: "error", title: "Failed to prepare backup for download", text: error });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -406,28 +256,37 @@ const unlockBackup = async (backupId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
watchEffect(() => {
|
||||
const hasOngoingBackups = backups.value.some((backup) => backup.ongoing);
|
||||
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value);
|
||||
refreshInterval.value = undefined;
|
||||
}
|
||||
|
||||
if (hasOngoingBackups) {
|
||||
refreshInterval.value = setInterval(async () => {
|
||||
await props.server.refresh(["backups"]);
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value);
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
async function deleteBackup(backup?: Backup) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<UiServersServerSidebar :route="route" :nav-links="navLinks" :server="props.server" />
|
||||
<UiServersServerSidebar
|
||||
:route="route"
|
||||
:nav-links="navLinks"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
InfoIcon,
|
||||
@@ -14,12 +18,14 @@ import {
|
||||
WrenchIcon,
|
||||
} from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const route = useRoute();
|
||||
const serverId = route.params.id as string;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
ref="versionSelectModal"
|
||||
:server="props.server"
|
||||
:current-loader="data?.loader as Loaders"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@reinstall="emit('reinstall', $event)"
|
||||
/>
|
||||
|
||||
@@ -93,13 +94,23 @@
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled>
|
||||
<nuxt-link class="!w-full sm:!w-auto" :to="`/modpacks?sid=${props.server.serverId}`">
|
||||
<nuxt-link
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:class="{ disabled: backupInProgress }"
|
||||
class="!w-full sm:!w-auto"
|
||||
:to="`/modpacks?sid=${props.server.serverId}`"
|
||||
>
|
||||
<CompassIcon class="size-4" /> Find a modpack
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<span class="hidden sm:block">or</span>
|
||||
<ButtonStyled>
|
||||
<button class="!w-full sm:!w-auto" @click="mrpackModal.show()">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="!!backupInProgress"
|
||||
class="!w-full sm:!w-auto"
|
||||
@click="mrpackModal.show()"
|
||||
>
|
||||
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -143,9 +154,13 @@ import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
|
||||
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -463,7 +463,7 @@ async function saveEmail() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -495,7 +495,7 @@ async function savePassword() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -532,7 +532,7 @@ async function showTwoFactorModal() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -622,7 +622,7 @@ async function deleteAccount() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -652,7 +652,7 @@ async function exportData() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,7 +196,10 @@
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircleIcon class="h-5 w-5 text-brand" />
|
||||
<span> {{ getPyroProduct(subscription)?.metadata?.cpu }} vCores (CPU) </span>
|
||||
<span>
|
||||
{{ getPyroProduct(subscription)?.metadata?.cpu / 2 }} Shared CPUs (Bursts up
|
||||
to {{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircleIcon class="h-5 w-5 text-brand" />
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
<Button v-if="avatarUrl !== null" :action="removePreviewImage">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.removeImageButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="previewImage"
|
||||
:action="
|
||||
@@ -86,7 +90,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon } from "@modrinth/assets";
|
||||
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { Avatar, FileInput, Button, commonMessages } from "@modrinth/ui";
|
||||
|
||||
useHead({
|
||||
@@ -142,6 +146,7 @@ const bio = ref(auth.value.user.bio);
|
||||
const avatarUrl = ref(auth.value.user.avatar_url);
|
||||
const icon = shallowRef(null);
|
||||
const previewImage = shallowRef(null);
|
||||
const pendingAvatarDeletion = ref(false);
|
||||
const saved = ref(false);
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
@@ -160,9 +165,15 @@ function showPreviewImage(files) {
|
||||
};
|
||||
}
|
||||
|
||||
function removePreviewImage() {
|
||||
pendingAvatarDeletion.value = true;
|
||||
previewImage.value = "https://cdn.modrinth.com/placeholder.png";
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
icon.value = null;
|
||||
previewImage.value = null;
|
||||
pendingAvatarDeletion.value = false;
|
||||
username.value = auth.value.user.username;
|
||||
bio.value = auth.value.user.bio;
|
||||
}
|
||||
@@ -170,6 +181,14 @@ function cancel() {
|
||||
async function saveChanges() {
|
||||
startLoading();
|
||||
try {
|
||||
if (pendingAvatarDeletion.value) {
|
||||
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
pendingAvatarDeletion.value = false;
|
||||
previewImage.value = null;
|
||||
}
|
||||
|
||||
if (icon.value) {
|
||||
await useBaseFetch(
|
||||
`user/${auth.value.user.id}/icon?ext=${
|
||||
|
||||
@@ -119,7 +119,7 @@ async function revokeSession(id) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,6 +212,18 @@ export interface WSNewModEvent {
|
||||
event: "new-mod";
|
||||
}
|
||||
|
||||
export type WSBackupTask = "file" | "create" | "restore";
|
||||
export type WSBackupState = "ongoing" | "done" | "failed" | "cancelled" | "unchanged";
|
||||
|
||||
export interface WSBackupProgressEvent {
|
||||
event: "backup-progress";
|
||||
task: WSBackupTask;
|
||||
id: string;
|
||||
progress: number; // percentage
|
||||
state: WSBackupState;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export type WSEvent =
|
||||
| WSLogEvent
|
||||
| WSStatsEvent
|
||||
@@ -221,7 +233,8 @@ export type WSEvent =
|
||||
| WSInstallationResultEvent
|
||||
| WSAuthOkEvent
|
||||
| WSUptimeEvent
|
||||
| WSNewModEvent;
|
||||
| WSNewModEvent
|
||||
| WSBackupProgressEvent;
|
||||
|
||||
export interface Servers {
|
||||
servers: Server[];
|
||||
|
||||
14
apps/labrinth/.sqlx/query-483cb875ba81c7563a2f7220158cfcb9e6a117a4efc070438606e4c94103a9a4.json
generated
Normal file
14
apps/labrinth/.sqlx/query-483cb875ba81c7563a2f7220158cfcb9e6a117a4efc070438606e4c94103a9a4.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE users\n SET avatar_url = NULL, raw_avatar_url = NULL\n WHERE (id = $1)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "483cb875ba81c7563a2f7220158cfcb9e6a117a4efc070438606e4c94103a9a4"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-79bdf720ec3631954c06fbba1da25b7e70db2e920ef1f2be77aa06f76da200fe.json
generated
Normal file
15
apps/labrinth/.sqlx/query-79bdf720ec3631954c06fbba1da25b7e70db2e920ef1f2be77aa06f76da200fe.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE users_subscriptions\n SET user_id = $1\n WHERE user_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "79bdf720ec3631954c06fbba1da25b7e70db2e920ef1f2be77aa06f76da200fe"
|
||||
}
|
||||
@@ -21,7 +21,6 @@ prometheus = "0.13.4"
|
||||
actix-web-prom = { version = "0.9.0", features = ["process"] }
|
||||
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
tracing-actix-web = "0.7.16"
|
||||
console-subscriber = "0.4.1"
|
||||
|
||||
@@ -29,7 +28,6 @@ tokio = { version = "1.35.1", features = ["sync", "rt-multi-thread"] }
|
||||
tokio-stream = "0.1.14"
|
||||
|
||||
futures = "0.3.30"
|
||||
futures-timer = "3.0.2"
|
||||
futures-util = "0.3.30"
|
||||
async-trait = "0.1.70"
|
||||
dashmap = "5.4.0"
|
||||
@@ -42,14 +40,11 @@ hyper = { version = "0.14", features = ["full"] }
|
||||
hyper-tls = "0.5.0"
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1.0"
|
||||
serde_cbor = "0.11"
|
||||
serde_with = "3.0.0"
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
yaserde = "0.12.0"
|
||||
yaserde_derive = "0.12.0"
|
||||
xml-rs = "0.8.15"
|
||||
|
||||
rand = "0.8.5"
|
||||
rand_chacha = "0.3.1"
|
||||
@@ -144,4 +139,4 @@ actix-http = "3.4.0"
|
||||
[build-dependencies]
|
||||
dotenv-build = "0.1.1"
|
||||
chrono = "0.4.38"
|
||||
iana-time-zone = "0.1.60"
|
||||
iana-time-zone = "0.1.60"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
## Modrinth's laboratory for its backend service & API!
|
||||
|
||||
|
||||
@@ -328,7 +328,8 @@ pub async fn is_visible_collection(
|
||||
collection_data: &Collection,
|
||||
user_option: &Option<User>,
|
||||
) -> Result<bool, ApiError> {
|
||||
let mut authorized = !collection_data.status.is_hidden();
|
||||
let mut authorized = !collection_data.status.is_hidden()
|
||||
&& !collection_data.projects.is_empty();
|
||||
if let Some(user) = &user_option {
|
||||
if !authorized
|
||||
&& (user.role.is_mod() || user.id == collection_data.user_id.into())
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::ids::{ProjectId, UserId};
|
||||
use super::{CollectionId, ReportId, ThreadId};
|
||||
use crate::database::models;
|
||||
use crate::database::models::charge_item::ChargeItem;
|
||||
use crate::database::models::user_subscription_item::UserSubscriptionItem;
|
||||
use crate::database::models::{DatabaseError, OrganizationId};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::billing::ChargeStatus;
|
||||
use crate::models::users::Badges;
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -657,6 +660,35 @@ impl User {
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
let open_subscriptions =
|
||||
UserSubscriptionItem::get_all_user(id, &mut **transaction)
|
||||
.await?;
|
||||
|
||||
for x in open_subscriptions {
|
||||
let charge =
|
||||
ChargeItem::get_open_subscription(x.id, &mut **transaction)
|
||||
.await?;
|
||||
if let Some(mut charge) = charge {
|
||||
charge.status = ChargeStatus::Cancelled;
|
||||
charge.due = Utc::now();
|
||||
charge.user_id = deleted_user;
|
||||
|
||||
charge.upsert(transaction).await?;
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users_subscriptions
|
||||
SET user_id = $1
|
||||
WHERE user_id = $2
|
||||
",
|
||||
deleted_user as UserId,
|
||||
id as UserId,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM user_backup_codes
|
||||
|
||||
@@ -224,8 +224,6 @@ impl RedisPool {
|
||||
+ Serialize,
|
||||
S: Display + Clone + DeserializeOwned + Serialize + Debug,
|
||||
{
|
||||
let connection = self.connect().await?.connection;
|
||||
|
||||
let ids = keys
|
||||
.iter()
|
||||
.map(|x| (x.to_string(), x.clone()))
|
||||
@@ -235,49 +233,21 @@ impl RedisPool {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let get_cached_values =
|
||||
|ids: DashMap<String, I>,
|
||||
mut connection: deadpool_redis::Connection| async move {
|
||||
let slug_ids = if let Some(slug_namespace) = slug_namespace {
|
||||
cmd("MGET")
|
||||
.arg(
|
||||
ids.iter()
|
||||
.map(|x| {
|
||||
format!(
|
||||
"{}_{slug_namespace}:{}",
|
||||
self.meta_namespace,
|
||||
if case_sensitive {
|
||||
x.value().to_string()
|
||||
} else {
|
||||
x.value().to_string().to_lowercase()
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let cached_values = cmd("MGET")
|
||||
let get_cached_values = |ids: DashMap<String, I>| async move {
|
||||
let slug_ids = if let Some(slug_namespace) = slug_namespace {
|
||||
let mut connection = self.pool.get().await?;
|
||||
cmd("MGET")
|
||||
.arg(
|
||||
ids.iter()
|
||||
.map(|x| x.value().to_string())
|
||||
.chain(ids.iter().filter_map(|x| {
|
||||
parse_base62(&x.value().to_string())
|
||||
.ok()
|
||||
.map(|x| x.to_string())
|
||||
}))
|
||||
.chain(slug_ids)
|
||||
.map(|x| {
|
||||
format!(
|
||||
"{}_{namespace}:{x}",
|
||||
self.meta_namespace
|
||||
"{}_{slug_namespace}:{}",
|
||||
self.meta_namespace,
|
||||
if case_sensitive {
|
||||
x.value().to_string()
|
||||
} else {
|
||||
x.value().to_string().to_lowercase()
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -285,23 +255,46 @@ impl RedisPool {
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
x.and_then(|val| {
|
||||
serde_json::from_str::<RedisValue<T, K, S>>(&val)
|
||||
.ok()
|
||||
})
|
||||
.map(|val| (val.key.clone(), val))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
Ok::<_, DatabaseError>((cached_values, connection, ids))
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut connection = self.pool.get().await?;
|
||||
let cached_values = cmd("MGET")
|
||||
.arg(
|
||||
ids.iter()
|
||||
.map(|x| x.value().to_string())
|
||||
.chain(ids.iter().filter_map(|x| {
|
||||
parse_base62(&x.value().to_string())
|
||||
.ok()
|
||||
.map(|x| x.to_string())
|
||||
}))
|
||||
.chain(slug_ids)
|
||||
.map(|x| {
|
||||
format!("{}_{namespace}:{x}", self.meta_namespace)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
x.and_then(|val| {
|
||||
serde_json::from_str::<RedisValue<T, K, S>>(&val).ok()
|
||||
})
|
||||
.map(|val| (val.key.clone(), val))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
Ok::<_, DatabaseError>((cached_values, ids))
|
||||
};
|
||||
|
||||
let current_time = Utc::now();
|
||||
let mut expired_values = HashMap::new();
|
||||
|
||||
let (cached_values_raw, mut connection, ids) =
|
||||
get_cached_values(ids, connection).await?;
|
||||
let (cached_values_raw, ids) = get_cached_values(ids).await?;
|
||||
let mut cached_values = cached_values_raw
|
||||
.into_iter()
|
||||
.filter_map(|(key, val)| {
|
||||
@@ -352,9 +345,12 @@ impl RedisPool {
|
||||
.with_expiration(SetExpiry::EX(60)),
|
||||
);
|
||||
});
|
||||
let results = pipe
|
||||
.query_async::<Vec<Option<i32>>>(&mut connection)
|
||||
.await?;
|
||||
let results = {
|
||||
let mut connection = self.pool.get().await?;
|
||||
|
||||
pipe.query_async::<Vec<Option<i32>>>(&mut connection)
|
||||
.await?
|
||||
};
|
||||
|
||||
for (idx, key) in fetch_ids.into_iter().enumerate() {
|
||||
if let Some(locked) = results.get(idx) {
|
||||
@@ -487,6 +483,7 @@ impl RedisPool {
|
||||
));
|
||||
}
|
||||
|
||||
let mut connection = self.pool.get().await?;
|
||||
pipe.query_async::<()>(&mut connection).await?;
|
||||
|
||||
Ok(return_values)
|
||||
@@ -495,28 +492,29 @@ impl RedisPool {
|
||||
|
||||
if !subscribe_ids.is_empty() {
|
||||
fetch_tasks.push(Box::pin(async {
|
||||
let mut connection = self.pool.get().await?;
|
||||
|
||||
let mut interval =
|
||||
tokio::time::interval(Duration::from_millis(100));
|
||||
let start = Utc::now();
|
||||
loop {
|
||||
let results = cmd("MGET")
|
||||
.arg(
|
||||
subscribe_ids
|
||||
.iter()
|
||||
.map(|x| {
|
||||
format!(
|
||||
"{}_{namespace}:{}/lock",
|
||||
self.meta_namespace,
|
||||
// We lowercase key because locks are stored in lowercase
|
||||
x.key().to_lowercase()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await?;
|
||||
let results = {
|
||||
let mut connection = self.pool.get().await?;
|
||||
cmd("MGET")
|
||||
.arg(
|
||||
subscribe_ids
|
||||
.iter()
|
||||
.map(|x| {
|
||||
format!(
|
||||
"{}_{namespace}:{}/lock",
|
||||
self.meta_namespace,
|
||||
// We lowercase key because locks are stored in lowercase
|
||||
x.key().to_lowercase()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await?
|
||||
};
|
||||
|
||||
if results.into_iter().all(|x| x.is_none()) {
|
||||
break;
|
||||
@@ -529,8 +527,8 @@ impl RedisPool {
|
||||
interval.tick().await;
|
||||
}
|
||||
|
||||
let (return_values, _, _) =
|
||||
get_cached_values(subscribe_ids, connection).await?;
|
||||
let (return_values, _) =
|
||||
get_cached_values(subscribe_ids).await?;
|
||||
|
||||
Ok(return_values)
|
||||
}));
|
||||
|
||||
@@ -189,8 +189,8 @@ impl ChargeType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ChargeType::OneTime => "one-time",
|
||||
ChargeType::Subscription { .. } => "subscription",
|
||||
ChargeType::Proration { .. } => "proration",
|
||||
ChargeType::Subscription => "subscription",
|
||||
ChargeType::Proration => "proration",
|
||||
ChargeType::Refund => "refund",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ pub async fn subscriptions(
|
||||
pub enum ChargeRefundAmount {
|
||||
Full,
|
||||
Partial { amount: u64 },
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -189,6 +190,7 @@ pub async fn refund_charge(
|
||||
let refund_amount = match body.0.amount {
|
||||
ChargeRefundAmount::Full => refundable,
|
||||
ChargeRefundAmount::Partial { amount } => amount as i64,
|
||||
ChargeRefundAmount::None => 0,
|
||||
};
|
||||
|
||||
if charge.status != ChargeStatus::Succeeded {
|
||||
@@ -197,55 +199,61 @@ pub async fn refund_charge(
|
||||
));
|
||||
}
|
||||
|
||||
if (refundable - refund_amount) < 0 || refund_amount == 0 {
|
||||
if (refundable - refund_amount) < 0 {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You cannot refund more than the amount of the charge!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (id, net) = match charge.payment_platform {
|
||||
PaymentPlatform::Stripe => {
|
||||
if let Some(payment_platform_id) = charge
|
||||
.payment_platform_id
|
||||
.and_then(|x| stripe::PaymentIntentId::from_str(&x).ok())
|
||||
{
|
||||
let mut metadata = HashMap::new();
|
||||
let (id, net) = if refund_amount == 0 {
|
||||
(None, None)
|
||||
} else {
|
||||
match charge.payment_platform {
|
||||
PaymentPlatform::Stripe => {
|
||||
if let Some(payment_platform_id) =
|
||||
charge.payment_platform_id.and_then(|x| {
|
||||
stripe::PaymentIntentId::from_str(&x).ok()
|
||||
})
|
||||
{
|
||||
let mut metadata = HashMap::new();
|
||||
|
||||
metadata.insert(
|
||||
"modrinth_user_id".to_string(),
|
||||
to_base62(user.id.0),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_charge_id".to_string(),
|
||||
to_base62(charge.id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_user_id".to_string(),
|
||||
to_base62(user.id.0),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_charge_id".to_string(),
|
||||
to_base62(charge.id.0 as u64),
|
||||
);
|
||||
|
||||
let refund = stripe::Refund::create(
|
||||
&stripe_client,
|
||||
CreateRefund {
|
||||
amount: Some(refund_amount),
|
||||
metadata: Some(metadata),
|
||||
payment_intent: Some(payment_platform_id),
|
||||
let refund = stripe::Refund::create(
|
||||
&stripe_client,
|
||||
CreateRefund {
|
||||
amount: Some(refund_amount),
|
||||
metadata: Some(metadata),
|
||||
payment_intent: Some(payment_platform_id),
|
||||
|
||||
expand: &["balance_transaction"],
|
||||
expand: &["balance_transaction"],
|
||||
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
(
|
||||
refund.id.to_string(),
|
||||
refund
|
||||
.balance_transaction
|
||||
.and_then(|x| x.into_object())
|
||||
.map(|x| x.net),
|
||||
)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Charge does not have attached payment id!".to_string(),
|
||||
));
|
||||
(
|
||||
Some(refund.id.to_string()),
|
||||
refund
|
||||
.balance_transaction
|
||||
.and_then(|x| x.into_object())
|
||||
.map(|x| x.net),
|
||||
)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Charge does not have attached payment id!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -266,7 +274,7 @@ pub async fn refund_charge(
|
||||
subscription_id: charge.subscription_id,
|
||||
subscription_interval: charge.subscription_interval,
|
||||
payment_platform: charge.payment_platform,
|
||||
payment_platform_id: Some(id),
|
||||
payment_platform_id: id,
|
||||
parent_charge_id: Some(charge.id),
|
||||
net,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.service(user_delete)
|
||||
.service(user_edit)
|
||||
.service(user_icon_edit)
|
||||
.service(user_icon_delete)
|
||||
.service(user_notifications)
|
||||
.service(user_follows),
|
||||
);
|
||||
@@ -223,6 +224,28 @@ pub async fn user_icon_edit(
|
||||
.or_else(v2_reroute::flatten_404_error)
|
||||
}
|
||||
|
||||
#[delete("{id}/icon")]
|
||||
pub async fn user_icon_delete(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so we don't need to convert to V2
|
||||
v3::users::user_icon_delete(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
file_host,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)
|
||||
}
|
||||
|
||||
#[delete("{id}")]
|
||||
pub async fn user_delete(
|
||||
req: HttpRequest,
|
||||
|
||||
@@ -188,7 +188,7 @@ pub async fn version_create(
|
||||
// Handle project type via file extension prediction
|
||||
let mut project_type = None;
|
||||
for file_part in &legacy_create.file_parts {
|
||||
if let Some(ext) = file_part.split('.').last() {
|
||||
if let Some(ext) = file_part.split('.').next_back() {
|
||||
match ext {
|
||||
"mrpack" | "mrpack-primary" => {
|
||||
project_type = Some("modpack");
|
||||
|
||||
@@ -50,7 +50,7 @@ pub struct CollectionCreateData {
|
||||
#[validate(length(min = 3, max = 255))]
|
||||
/// A short description of the collection.
|
||||
pub description: Option<String>,
|
||||
#[validate(length(max = 32))]
|
||||
#[validate(length(max = 1024))]
|
||||
#[serde(default = "Vec::new")]
|
||||
/// A list of initial projects to use with the created collection
|
||||
pub projects: Vec<String>,
|
||||
|
||||
@@ -936,19 +936,26 @@ pub async fn transfer_ownership(
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
// The following are the only places new_is_owner is modified.
|
||||
TeamMember::edit_team_member(
|
||||
id.into(),
|
||||
current_user.id.into(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(false),
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
if let Some(former_owner) =
|
||||
TeamMember::get_from_team_full(id.into(), &**pool, &redis)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|x| x.is_owner)
|
||||
{
|
||||
TeamMember::edit_team_member(
|
||||
id.into(),
|
||||
former_owner.user_id,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(false),
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
TeamMember::edit_team_member(
|
||||
id.into(),
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.route("{user_id}/organizations", web::get().to(orgs_list))
|
||||
.route("{id}", web::patch().to(user_edit))
|
||||
.route("{id}/icon", web::patch().to(user_icon_edit))
|
||||
.route("{id}/icon", web::delete().to(user_icon_delete))
|
||||
.route("{id}", web::delete().to(user_delete))
|
||||
.route("{id}/follows", web::get().to(user_follows))
|
||||
.route("{id}/notifications", web::get().to(user_notifications))
|
||||
@@ -623,6 +624,59 @@ pub async fn user_icon_edit(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_icon_delete(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::USER_WRITE]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(actual_user) = id_option {
|
||||
if user.id != actual_user.id.into() && !user.role.is_mod() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit this user's icon."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
delete_old_images(
|
||||
actual_user.avatar_url,
|
||||
actual_user.raw_avatar_url,
|
||||
&***file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET avatar_url = NULL, raw_avatar_url = NULL
|
||||
WHERE (id = $1)
|
||||
",
|
||||
actual_user.id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
|
||||
User::clear_caches(&[(actual_user.id, None)], &redis).await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_delete(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
|
||||
@@ -155,7 +155,7 @@ pub async fn get_update_from_hash(
|
||||
database::models::Project::get_id(file.project_id, &**pool, &redis)
|
||||
.await?
|
||||
{
|
||||
let versions = database::models::Version::get_many(
|
||||
let mut versions = database::models::Version::get_many(
|
||||
&project.versions,
|
||||
&**pool,
|
||||
&redis,
|
||||
@@ -191,7 +191,7 @@ pub async fn get_update_from_hash(
|
||||
})
|
||||
.sorted();
|
||||
|
||||
if let Some(first) = versions.last() {
|
||||
if let Some(first) = versions.next_back() {
|
||||
if !is_visible_version(
|
||||
&first.inner,
|
||||
&user_option,
|
||||
@@ -523,7 +523,7 @@ pub async fn update_individual_files(
|
||||
bool
|
||||
})
|
||||
.sorted()
|
||||
.last();
|
||||
.next_back();
|
||||
|
||||
if let Some(version) = version {
|
||||
if is_visible_version(
|
||||
|
||||
@@ -9,7 +9,6 @@ bytes = "1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_ini = "0.2.0"
|
||||
toml = "0.8.12"
|
||||
sha1_smol = { version = "1.0.0", features = ["std"] }
|
||||
sha2 = "0.10.8"
|
||||
url = "2.2"
|
||||
@@ -18,7 +17,6 @@ zip = "0.6.5"
|
||||
async_zip = { version = "0.0.17", features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] }
|
||||
flate2 = "1.0.28"
|
||||
tempfile = "3.5.0"
|
||||
urlencoding = "2.1.3"
|
||||
dashmap = { version = "6.0.1", features = ["serde"] }
|
||||
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
|
||||
@@ -78,7 +78,7 @@ pub async fn import_curseforge(
|
||||
let icon_bytes =
|
||||
fetch(&thumbnail_url, None, &state.fetch_semaphore, &state.pool)
|
||||
.await?;
|
||||
let filename = thumbnail_url.rsplit('/').last();
|
||||
let filename = thumbnail_url.rsplit('/').next_back();
|
||||
if let Some(filename) = filename {
|
||||
icon = Some(
|
||||
write_cached_icon(
|
||||
|
||||
@@ -139,9 +139,7 @@ pub async fn write(
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, "background task failed")
|
||||
})??;
|
||||
.map_err(|_| std::io::Error::other("background task failed"))??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -152,8 +150,7 @@ fn sync_write(
|
||||
) -> Result<(), std::io::Error> {
|
||||
let mut tempfile =
|
||||
NamedTempFile::new_in(path.as_ref().parent().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
std::io::Error::other(
|
||||
"could not get parent directory for temporary file",
|
||||
)
|
||||
})?)?;
|
||||
|
||||
1
packages/assets/icons/bot.svg
Normal file
1
packages/assets/icons/bot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot-icon lucide-bot"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
1
packages/assets/icons/folder-archive.svg
Normal file
1
packages/assets/icons/folder-archive.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-archive-icon lucide-folder-archive"><circle cx="15" cy="19" r="2"/><path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1"/><path d="M15 11v-1"/><path d="M15 17v-2"/></svg>
|
||||
|
After Width: | Height: | Size: 463 B |
1
packages/assets/icons/rotate-ccw.svg
Normal file
1
packages/assets/icons/rotate-ccw.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw-icon lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
|
After Width: | Height: | Size: 324 B |
1
packages/assets/icons/rotate-cw.svg
Normal file
1
packages/assets/icons/rotate-cw.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-cw-icon lucide-rotate-cw"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
|
After Width: | Height: | Size: 324 B |
@@ -40,6 +40,7 @@ import _BellRingIcon from './icons/bell-ring.svg?component'
|
||||
import _BookIcon from './icons/book.svg?component'
|
||||
import _BookTextIcon from './icons/book-text.svg?component'
|
||||
import _BookmarkIcon from './icons/bookmark.svg?component'
|
||||
import _BotIcon from './icons/bot.svg?component'
|
||||
import _BoxIcon from './icons/box.svg?component'
|
||||
import _BoxImportIcon from './icons/box-import.svg?component'
|
||||
import _BracesIcon from './icons/braces.svg?component'
|
||||
@@ -76,6 +77,7 @@ import _FileIcon from './icons/file.svg?component'
|
||||
import _FileTextIcon from './icons/file-text.svg?component'
|
||||
import _FilterIcon from './icons/filter.svg?component'
|
||||
import _FilterXIcon from './icons/filter-x.svg?component'
|
||||
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
|
||||
import _FolderOpenIcon from './icons/folder-open.svg?component'
|
||||
import _FolderSearchIcon from './icons/folder-search.svg?component'
|
||||
import _GapIcon from './icons/gap.svg?component'
|
||||
@@ -137,6 +139,8 @@ import _ReplyIcon from './icons/reply.svg?component'
|
||||
import _ReportIcon from './icons/report.svg?component'
|
||||
import _RestoreIcon from './icons/restore.svg?component'
|
||||
import _RightArrowIcon from './icons/right-arrow.svg?component'
|
||||
import _RotateCounterClockwiseIcon from './icons/rotate-ccw.svg?component'
|
||||
import _RotateClockwiseIcon from './icons/rotate-cw.svg?component'
|
||||
import _SaveIcon from './icons/save.svg?component'
|
||||
import _ScaleIcon from './icons/scale.svg?component'
|
||||
import _ScanEyeIcon from './icons/scan-eye.svg?component'
|
||||
@@ -254,6 +258,7 @@ export const BellRingIcon = _BellRingIcon
|
||||
export const BookIcon = _BookIcon
|
||||
export const BookTextIcon = _BookTextIcon
|
||||
export const BookmarkIcon = _BookmarkIcon
|
||||
export const BotIcon = _BotIcon
|
||||
export const BoxIcon = _BoxIcon
|
||||
export const BoxImportIcon = _BoxImportIcon
|
||||
export const BracesIcon = _BracesIcon
|
||||
@@ -290,6 +295,7 @@ export const FileIcon = _FileIcon
|
||||
export const FileTextIcon = _FileTextIcon
|
||||
export const FilterIcon = _FilterIcon
|
||||
export const FilterXIcon = _FilterXIcon
|
||||
export const FolderArchiveIcon = _FolderArchiveIcon
|
||||
export const FolderOpenIcon = _FolderOpenIcon
|
||||
export const FolderSearchIcon = _FolderSearchIcon
|
||||
export const GapIcon = _GapIcon
|
||||
@@ -351,6 +357,8 @@ export const ReplyIcon = _ReplyIcon
|
||||
export const ReportIcon = _ReportIcon
|
||||
export const RestoreIcon = _RestoreIcon
|
||||
export const RightArrowIcon = _RightArrowIcon
|
||||
export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon
|
||||
export const RotateClockwiseIcon = _RotateClockwiseIcon
|
||||
export const SaveIcon = _SaveIcon
|
||||
export const ScaleIcon = _ScaleIcon
|
||||
export const ScanEyeIcon = _ScanEyeIcon
|
||||
|
||||
@@ -17,5 +17,4 @@ readme = "README.md"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
bytes = "1"
|
||||
thiserror = "1.0"
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<div v-bind="$attrs">
|
||||
<button
|
||||
v-if="!!slots.title"
|
||||
:class="buttonClass ?? 'flex flex-col gap-2'"
|
||||
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
|
||||
@click="() => (isOpen ? close() : open())"
|
||||
>
|
||||
<slot name="button" :open="isOpen">
|
||||
<div class="flex items-center w-full">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<slot name="title" />
|
||||
<DropdownIcon
|
||||
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-contrast"
|
||||
class="ml-auto size-5 transition-transform duration-300 shrink-0"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold">
|
||||
<div class="font-semibold flex justify-between gap-4">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
</div>
|
||||
<div class="font-normal">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto w-fit">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const props = withDefaults(
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
size?: 'standard' | 'large'
|
||||
circular?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight'
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
|
||||
colorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
hoverColorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
highlightedStyle?: 'main-nav-primary' | 'main-nav-secondary'
|
||||
@@ -24,20 +24,40 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const highlightedColorVar = computed(() => {
|
||||
switch (props.color) {
|
||||
case 'brand':
|
||||
return 'var(--color-brand-highlight)'
|
||||
case 'red':
|
||||
return 'var(--color-red-highlight)'
|
||||
case 'orange':
|
||||
return 'var(--color-orange-highlight)'
|
||||
case 'green':
|
||||
return 'var(--color-green-highlight)'
|
||||
case 'blue':
|
||||
return 'var(--color-blue-highlight)'
|
||||
case 'purple':
|
||||
return 'var(--color-purple-highlight)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const colorVar = computed(() => {
|
||||
switch (props.color) {
|
||||
case 'brand':
|
||||
return props.type === 'highlight' ? 'var(--color-brand-highlight)' : 'var(--color-brand)'
|
||||
return 'var(--color-brand)'
|
||||
case 'red':
|
||||
return props.type === 'highlight' ? 'var(--color-red-highlight)' : 'var(--color-red)'
|
||||
return 'var(--color-red)'
|
||||
case 'orange':
|
||||
return props.type === 'highlight' ? 'var(--color-orange-highlight)' : 'var(--color-orange)'
|
||||
return 'var(--color-orange)'
|
||||
case 'green':
|
||||
return props.type === 'highlight' ? 'var(--color-green-highlight)' : 'var(--color-green)'
|
||||
return 'var(--color-green)'
|
||||
case 'blue':
|
||||
return props.type === 'highlight' ? 'var(--color-blue-highlight)' : 'var(--color-blue)'
|
||||
return 'var(--color-blue)'
|
||||
case 'purple':
|
||||
return props.type === 'highlight' ? 'var(--color-purple-highlight)' : 'var(--color-purple)'
|
||||
return 'var(--color-purple)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
@@ -111,10 +131,14 @@ function setColorFill(
|
||||
): { bg: string; text: string } {
|
||||
if (colorVar.value) {
|
||||
if (fill === 'background') {
|
||||
colors.bg = colorVar.value
|
||||
if (props.type === 'highlight') {
|
||||
if (props.type === 'highlight' && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = 'var(--color-contrast)'
|
||||
} else if (props.type === 'highlight-colored-text' && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = colorVar.value
|
||||
} else {
|
||||
colors.bg = colorVar.value
|
||||
colors.text = 'var(--color-accent-contrast)'
|
||||
}
|
||||
} else if (fill === 'text') {
|
||||
@@ -195,7 +219,7 @@ const colorVariables = computed(() => {
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
|
||||
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
background-color 0.25s ease-in-out,
|
||||
@@ -204,6 +228,7 @@ const colorVariables = computed(() => {
|
||||
svg:first-child {
|
||||
color: var(--_icon, var(--_text));
|
||||
transition: color 0.25s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
</h1>
|
||||
<slot name="title-suffix" />
|
||||
</div>
|
||||
<p class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
||||
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
||||
<slot name="summary" />
|
||||
</p>
|
||||
<div class="mt-auto flex flex-wrap gap-4 empty:hidden">
|
||||
<div v-if="$slots.stats" class="mt-auto flex flex-wrap gap-4 empty:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
83
packages/ui/src/components/base/ProgressBar.vue
Normal file
83
packages/ui/src/components/base/ProgressBar.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
progress: number
|
||||
max?: number
|
||||
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
|
||||
waiting?: boolean
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
color: 'brand',
|
||||
waiting: false,
|
||||
},
|
||||
)
|
||||
|
||||
const colors = {
|
||||
brand: {
|
||||
fg: 'bg-brand',
|
||||
bg: 'bg-brand-highlight',
|
||||
},
|
||||
green: {
|
||||
fg: 'bg-green',
|
||||
bg: 'bg-bg-green',
|
||||
},
|
||||
red: {
|
||||
fg: 'bg-red',
|
||||
bg: 'bg-bg-red',
|
||||
},
|
||||
orange: {
|
||||
fg: 'bg-orange',
|
||||
bg: 'bg-bg-orange',
|
||||
},
|
||||
blue: {
|
||||
fg: 'bg-blue',
|
||||
bg: 'bg-bg-blue',
|
||||
},
|
||||
purple: {
|
||||
fg: 'bg-purple',
|
||||
bg: 'bg-bg-purple',
|
||||
},
|
||||
gray: {
|
||||
fg: 'bg-gray',
|
||||
bg: 'bg-bg-gray',
|
||||
},
|
||||
}
|
||||
|
||||
const percent = computed(() => props.progress / props.max)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex w-[15rem] h-1 rounded-full overflow-hidden" :class="colors[props.color].bg">
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.progress-bar {
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.progress-bar--waiting {
|
||||
animation: progress-bar-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes progress-bar-waiting {
|
||||
0% {
|
||||
left: -50%;
|
||||
width: 20%;
|
||||
}
|
||||
50% {
|
||||
width: 60%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
99
packages/ui/src/components/base/ServerNotice.vue
Normal file
99
packages/ui/src/components/base/ServerNotice.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="level === 'survey'"
|
||||
class="flex items-center gap-2 border-2 border-solid border-brand-purple bg-bg-purple p-4 rounded-2xl"
|
||||
>
|
||||
<span class="text-contrast font-bold">Survey ID:</span> <CopyCode :text="message" />
|
||||
</div>
|
||||
<Admonition v-else :type="NOTICE_TYPE[level]">
|
||||
<template #header>
|
||||
<template v-if="!hideDefaultTitle">
|
||||
{{ formatMessage(heading) }}
|
||||
</template>
|
||||
<template v-if="title">
|
||||
<template v-if="hideDefaultTitle">
|
||||
{{ title.substring(1) }}
|
||||
</template>
|
||||
<template v-else> - {{ title }}</template>
|
||||
</template>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="dismissable" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismiss)"
|
||||
@click="() => (preview ? {} : emit('dismiss'))"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<div v-if="message" class="markdown-body" v-html="renderString(message)" />
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { Admonition } from '../index'
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import CopyCode from './CopyCode.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
(e: 'dismiss'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
level: string
|
||||
message: string
|
||||
dismissable: boolean
|
||||
preview?: boolean
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
title: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const hideDefaultTitle = computed(
|
||||
() => props.title && props.title.length > 1 && props.title.startsWith('\\'),
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
id: 'servers.notice.heading.info',
|
||||
defaultMessage: 'Info',
|
||||
},
|
||||
attention: {
|
||||
id: 'servers.notice.heading.attention',
|
||||
defaultMessage: 'Attention',
|
||||
},
|
||||
dismiss: {
|
||||
id: 'servers.notice.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
},
|
||||
})
|
||||
|
||||
const NOTICE_HEADINGS: Record<string, MessageDescriptor> = {
|
||||
info: messages.info,
|
||||
warn: messages.attention,
|
||||
critical: messages.attention,
|
||||
}
|
||||
|
||||
const NOTICE_TYPE: Record<string, 'info' | 'warning' | 'critical'> = {
|
||||
info: 'info',
|
||||
warn: 'warning',
|
||||
critical: 'critical',
|
||||
}
|
||||
|
||||
const heading = computed(() => NOTICE_HEADINGS[props.level] ?? messages.info)
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.markdown-body > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -106,13 +106,16 @@
|
||||
</p>
|
||||
<IssuesIcon
|
||||
v-if="customServerConfig.ramInGb < 4"
|
||||
v-tooltip="'This might not be enough resources for your Minecraft server.'"
|
||||
v-tooltip="'This might not be powerful enough for your Minecraft server.'"
|
||||
class="h-6 w-6 text-orange"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="existingPlan" class="mt-1 mb-2 text-secondary">
|
||||
Your current plan has <strong>{{ existingPlan.metadata.ram / 1024 }} GB RAM</strong> and
|
||||
<strong>{{ existingPlan.metadata.cpu }} vCPUs</strong>.
|
||||
<strong
|
||||
>{{ existingPlan.metadata.cpu / 2 }} shared CPUs (bursts up to
|
||||
{{ existingPlan.metadata.cpu }} CPUs)</strong
|
||||
>.
|
||||
</p>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex w-full gap-2 items-center">
|
||||
@@ -131,12 +134,28 @@
|
||||
class="flex sm:flex-row flex-col gap-4 w-full"
|
||||
>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="font-semibold">vCPUs</div>
|
||||
<input v-model="mutatedProduct.metadata.cpu" disabled class="input" />
|
||||
<div class="font-semibold">Shared CPUs</div>
|
||||
<input :value="sharedCpus" disabled class="input w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="font-semibold flex items-center gap-1">
|
||||
Max Burst CPUs
|
||||
<UnknownIcon
|
||||
v-tooltip="
|
||||
'CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. See Modrinth Servers FAQ for more info.'
|
||||
"
|
||||
class="h-4 w-4text-secondary opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<input :value="mutatedProduct.metadata.cpu" disabled class="input w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="font-semibold">Storage</div>
|
||||
<input v-model="customServerConfig.storageGbFormatted" disabled class="input" />
|
||||
<input
|
||||
v-model="customServerConfig.storageGbFormatted"
|
||||
disabled
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Admonition
|
||||
@@ -153,10 +172,11 @@
|
||||
later, or try a different amount.
|
||||
</Admonition>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<div class="flex gap-2">
|
||||
<InfoIcon class="hidden sm:block shrink-0 mt-1" />
|
||||
<span class="text-sm text-secondary">
|
||||
Storage and vCPUs are currently not configurable.
|
||||
Storage and shared CPU count are currently not configurable independently, and are
|
||||
based on the amount of RAM you select.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,6 +520,7 @@
|
||||
import { ref, computed, nextTick, reactive, watch } from 'vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import {
|
||||
UnknownIcon,
|
||||
SpinnerIcon,
|
||||
CardIcon,
|
||||
CheckCircleIcon,
|
||||
@@ -765,7 +786,11 @@ function updateRamValues() {
|
||||
customMinRam.value = Math.min(...ramValues)
|
||||
customMaxRam.value = Math.max(...ramValues)
|
||||
|
||||
customServerConfig.ramInGb = customMinRam.value
|
||||
if (props.product.some((product) => product.metadata.ram / 1024 === 4)) {
|
||||
customServerConfig.ramInGb = 4
|
||||
} else {
|
||||
customServerConfig.ramInGb = customMinRam.value
|
||||
}
|
||||
}
|
||||
|
||||
if (props.customServer) {
|
||||
@@ -832,6 +857,10 @@ const metadata = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const sharedCpus = computed(() => {
|
||||
return (mutatedProduct.value?.metadata?.cpu ?? 0) / 2
|
||||
})
|
||||
|
||||
function nextStep() {
|
||||
if (
|
||||
mutatedProduct.value.metadata.type === 'pyro' &&
|
||||
|
||||
@@ -26,10 +26,12 @@ export { default as Page } from './base/Page.vue'
|
||||
export { default as Pagination } from './base/Pagination.vue'
|
||||
export { default as PopoutMenu } from './base/PopoutMenu.vue'
|
||||
export { default as PreviewSelectButton } from './base/PreviewSelectButton.vue'
|
||||
export { default as ProgressBar } from './base/ProgressBar.vue'
|
||||
export { default as ProjectCard } from './base/ProjectCard.vue'
|
||||
export { default as RadialHeader } from './base/RadialHeader.vue'
|
||||
export { default as RadioButtons } from './base/RadioButtons.vue'
|
||||
export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
|
||||
export { default as ServerNotice } from './base/ServerNotice.vue'
|
||||
export { default as SimpleBadge } from './base/SimpleBadge.vue'
|
||||
export { default as Slider } from './base/Slider.vue'
|
||||
export { default as StatItem } from './base/StatItem.vue'
|
||||
@@ -97,3 +99,6 @@ export { default as VersionSummary } from './version/VersionSummary.vue'
|
||||
|
||||
// Settings
|
||||
export { default as ThemeSelector } from './settings/ThemeSelector.vue'
|
||||
|
||||
// Servers
|
||||
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
|
||||
|
||||
@@ -5,26 +5,28 @@
|
||||
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
<div>
|
||||
<div class="markdown-body max-w-[35rem]" v-html="renderString(description)" />
|
||||
<label v-if="hasToType" for="confirmation" class="confirmation-label">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="description"
|
||||
class="markdown-body max-w-[35rem]"
|
||||
v-html="renderString(description)"
|
||||
/>
|
||||
<slot />
|
||||
<label v-if="hasToType" for="confirmation">
|
||||
<span>
|
||||
<strong>To verify, type</strong>
|
||||
<em class="confirmation-text"> {{ confirmationText }} </em>
|
||||
<strong>below:</strong>
|
||||
To confirm you want to proceed, type
|
||||
<span class="italic font-bold">{{ confirmationText }}</span> below:
|
||||
</span>
|
||||
</label>
|
||||
<div class="confirmation-input">
|
||||
<input
|
||||
v-if="hasToType"
|
||||
id="confirmation"
|
||||
v-model="confirmation_typed"
|
||||
type="text"
|
||||
placeholder="Type here..."
|
||||
@input="type"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-6">
|
||||
<input
|
||||
v-if="hasToType"
|
||||
id="confirmation"
|
||||
v-model="confirmation_typed"
|
||||
type="text"
|
||||
placeholder="Type here..."
|
||||
class="max-w-[20rem]"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled :color="danger ? 'red' : 'brand'">
|
||||
<button :disabled="action_disabled" @click="proceed">
|
||||
<component :is="proceedIcon" />
|
||||
@@ -65,8 +67,8 @@ const props = defineProps({
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'No description defined',
|
||||
required: true,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
proceedIcon: {
|
||||
type: Object,
|
||||
@@ -95,21 +97,20 @@ const props = defineProps({
|
||||
const emit = defineEmits(['proceed'])
|
||||
const modal = ref(null)
|
||||
|
||||
const action_disabled = ref(props.hasToType)
|
||||
const confirmation_typed = ref('')
|
||||
|
||||
const action_disabled = computed(
|
||||
() =>
|
||||
props.hasToType &&
|
||||
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase(),
|
||||
)
|
||||
|
||||
function proceed() {
|
||||
modal.value.hide()
|
||||
confirmation_typed.value = ''
|
||||
emit('proceed')
|
||||
}
|
||||
|
||||
function type() {
|
||||
if (props.hasToType) {
|
||||
action_disabled.value =
|
||||
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
25
packages/ui/src/components/servers/backups/BackupWarning.vue
Normal file
25
packages/ui/src/components/servers/backups/BackupWarning.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { IssuesIcon } from '@modrinth/assets'
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
backupLink: string
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-3 rounded-2xl border-2 border-solid border-orange bg-bg-orange px-4 py-3 font-medium text-contrast"
|
||||
>
|
||||
<IssuesIcon class="mt-1 h-5 w-5 shrink-0 text-orange" />
|
||||
<span class="leading-normal">
|
||||
You may want to
|
||||
<AutoLink
|
||||
:to="backupLink"
|
||||
class="font-semibold text-orange hover:underline active:brightness-125"
|
||||
>create a backup</AutoLink
|
||||
>
|
||||
before proceeding, as this process is irreversible and may permanently alter your world or the
|
||||
files on your server.
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,6 +11,12 @@
|
||||
"button.create-a-project": {
|
||||
"defaultMessage": "Create a project"
|
||||
},
|
||||
"button.download": {
|
||||
"defaultMessage": "Download"
|
||||
},
|
||||
"button.downloading": {
|
||||
"defaultMessage": "Downloading"
|
||||
},
|
||||
"button.edit": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
@@ -404,6 +410,33 @@
|
||||
"search.filter_type.shader_loader": {
|
||||
"defaultMessage": "Loader"
|
||||
},
|
||||
"servers.notice.dismiss": {
|
||||
"defaultMessage": "Dismiss"
|
||||
},
|
||||
"servers.notice.dismissable": {
|
||||
"defaultMessage": "Dismissable"
|
||||
},
|
||||
"servers.notice.heading.attention": {
|
||||
"defaultMessage": "Attention"
|
||||
},
|
||||
"servers.notice.heading.info": {
|
||||
"defaultMessage": "Info"
|
||||
},
|
||||
"servers.notice.level.critical.name": {
|
||||
"defaultMessage": "Critical"
|
||||
},
|
||||
"servers.notice.level.info.name": {
|
||||
"defaultMessage": "Info"
|
||||
},
|
||||
"servers.notice.level.survey.name": {
|
||||
"defaultMessage": "Survey"
|
||||
},
|
||||
"servers.notice.level.warn.name": {
|
||||
"defaultMessage": "Warning"
|
||||
},
|
||||
"servers.notice.undismissable": {
|
||||
"defaultMessage": "Undismissable"
|
||||
},
|
||||
"settings.account.title": {
|
||||
"defaultMessage": "Account and security"
|
||||
},
|
||||
|
||||
@@ -49,6 +49,14 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.description',
|
||||
defaultMessage: 'Description',
|
||||
},
|
||||
downloadButton: {
|
||||
id: 'button.download',
|
||||
defaultMessage: 'Download',
|
||||
},
|
||||
downloadingButton: {
|
||||
id: 'button.downloading',
|
||||
defaultMessage: 'Downloading',
|
||||
},
|
||||
editButton: {
|
||||
id: 'button.edit',
|
||||
defaultMessage: 'Edit',
|
||||
@@ -145,6 +153,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'button.upload-image',
|
||||
defaultMessage: 'Upload image',
|
||||
},
|
||||
removeImageButton: {
|
||||
id: 'button.remove-image',
|
||||
defaultMessage: 'Remove image',
|
||||
},
|
||||
visibilityLabel: {
|
||||
id: 'label.visibility',
|
||||
defaultMessage: 'Visibility',
|
||||
|
||||
73
packages/ui/src/utils/notices.ts
Normal file
73
packages/ui/src/utils/notices.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { defineMessage, type MessageDescriptor } from '@vintl/vintl'
|
||||
|
||||
export const NOTICE_LEVELS: Record<
|
||||
string,
|
||||
{ name: MessageDescriptor; colors: { text: string; bg: string } }
|
||||
> = {
|
||||
info: {
|
||||
name: defineMessage({
|
||||
id: 'servers.notice.level.info.name',
|
||||
defaultMessage: 'Info',
|
||||
}),
|
||||
colors: {
|
||||
text: 'var(--color-blue)',
|
||||
bg: 'var(--color-blue-bg)',
|
||||
},
|
||||
},
|
||||
warn: {
|
||||
name: defineMessage({
|
||||
id: 'servers.notice.level.warn.name',
|
||||
defaultMessage: 'Warning',
|
||||
}),
|
||||
colors: {
|
||||
text: 'var(--color-orange)',
|
||||
bg: 'var(--color-orange-bg)',
|
||||
},
|
||||
},
|
||||
critical: {
|
||||
name: defineMessage({
|
||||
id: 'servers.notice.level.critical.name',
|
||||
defaultMessage: 'Critical',
|
||||
}),
|
||||
colors: {
|
||||
text: 'var(--color-red)',
|
||||
bg: 'var(--color-red-bg)',
|
||||
},
|
||||
},
|
||||
survey: {
|
||||
name: defineMessage({
|
||||
id: 'servers.notice.level.survey.name',
|
||||
defaultMessage: 'Survey',
|
||||
}),
|
||||
colors: {
|
||||
text: 'var(--color-purple)',
|
||||
bg: 'var(--color-purple-bg)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const DISMISSABLE = {
|
||||
name: defineMessage({
|
||||
id: 'servers.notice.dismissable',
|
||||
defaultMessage: 'Dismissable',
|
||||
}),
|
||||
colors: {
|
||||
text: 'var(--color-green)',
|
||||
bg: 'var(--color-green-bg)',
|
||||
},
|
||||
}
|
||||
|
||||
const UNDISMISSABLE = {
|
||||
name: defineMessage({
|
||||
id: 'servers.notice.undismissable',
|
||||
defaultMessage: 'Undismissable',
|
||||
}),
|
||||
colors: {
|
||||
text: 'var(--color-red)',
|
||||
bg: 'var(--color-red-bg)',
|
||||
},
|
||||
}
|
||||
|
||||
export function getDismissableMetadata(dismissable: boolean) {
|
||||
return dismissable ? DISMISSABLE : UNDISMISSABLE
|
||||
}
|
||||
@@ -10,6 +10,45 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2025-04-18T22:30:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Updated Modrinth Servers marketing page to be accurate to post-Pyro infrastructure.`,
|
||||
},
|
||||
{
|
||||
date: `2025-04-17T02:25:00-07:00`,
|
||||
product: 'servers',
|
||||
body: `### Improvements
|
||||
- Completely overhauled the Backups interface and fixed them being non-functional.
|
||||
- Backups will now show progress when creating and restoring.
|
||||
- Backups now have a "Prepare download" phase, which will prepare a backup file for downloading.
|
||||
- You can now cancel a backup in progress and retry a failed backup.
|
||||
- When a backup is in progress, you will no longer be allowed to modify the modpack or loader.
|
||||
- Removed the ability to create backups on install automatically, and replaced with a notice that you may want to create a backup before installing a new modpack or loader. This is because the previous implementation of backup on install was unreliable and buggy. We are working on a better implementation for this feature and plan for it to return in the future.
|
||||
- Temporarily disabled auto backups button, since they are currently not working.`,
|
||||
},
|
||||
{
|
||||
date: `2025-04-15T16:35:00-07:00`,
|
||||
product: 'servers',
|
||||
body: `### Added
|
||||
- Added ability to send surveys to customers in the panel via notices.
|
||||
|
||||
### Improvements
|
||||
- Added titles to notices.`,
|
||||
},
|
||||
{
|
||||
date: `2025-04-12T22:10:00-07:00`,
|
||||
product: 'servers',
|
||||
body: `### Added
|
||||
- Added ability to notify customers in the panel with notices concerning their servers.`,
|
||||
},
|
||||
{
|
||||
date: `2025-04-12T22:10:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Fix missing dropdown icon in publishing checklist.`,
|
||||
},
|
||||
{
|
||||
date: `2025-04-01T21:15:00-07:00`,
|
||||
product: 'web',
|
||||
|
||||
@@ -252,3 +252,22 @@ export type Report = {
|
||||
created: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type ServerNotice = {
|
||||
id: number
|
||||
message: string
|
||||
title?: string
|
||||
level: 'info' | 'warn' | 'critical' | 'survey'
|
||||
dismissable: boolean
|
||||
announce_at: string
|
||||
expires: string
|
||||
assigned: {
|
||||
kind: 'server' | 'node'
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
dismissed_by: {
|
||||
server: string
|
||||
dismissed_on: string
|
||||
}[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user