Merge commit 'daf699911104207a477751916b36a371ee8f7e38' into feature-clean

This commit is contained in:
2025-04-19 17:29:54 +03:00
89 changed files with 4249 additions and 2575 deletions

View File

@@ -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",
});
}

View File

@@ -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",
});
}

View File

@@ -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",
});
}

View File

@@ -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({

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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[]) => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>