Add notices system to Servers (#3502)

* Servers notices

* Refresh on unassign
This commit is contained in:
Prospector 2025-04-12 22:00:22 -07:00 committed by GitHub
parent 56520572b2
commit 59edc8d618
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1065 additions and 7 deletions

View File

@ -89,6 +89,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 +112,7 @@ import {
ServerIcon,
InfoIcon,
MoreVerticalIcon,
ClipboardCopyIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router";
@ -116,6 +121,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 +205,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

@ -0,0 +1,201 @@
<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"
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,118 @@
<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"
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>

View File

@ -1,5 +1,6 @@
// 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";
interface PyroFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@ -289,6 +290,7 @@ interface General {
sftp_password: string;
sftp_host: string;
datacenter?: string;
notices?: ServerNotice[];
}
interface Allocation {

View File

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

View File

@ -959,6 +959,33 @@
"search.filter.locked.server.sync": {
"message": "Sync with server"
},
"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"
},
"settings.billing.modal.cancel.action": {
"message": "Cancel subscription"
},

View File

@ -0,0 +1,470 @@
<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 flex-col gap-2">
<label for="notice-message" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Message
<span class="text-brand-red">*</span>
</span>
</label>
<div class="textarea-wrapper">
<textarea id="notice-message" v-model="newNoticeMessage" maxlength="256" />
</div>
</div>
<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 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"
maxlength="128"
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"
maxlength="128"
autocomplete="off"
/>
</div>
<div 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"
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"
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>
</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 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;
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 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,
level: newNoticeLevel.value.id,
dismissable: newNoticeDismissable.value,
announce_at: newNoticeScheduledDate.value
? dayjs(newNoticeScheduledDate.value).toISOString()
: dayjs().toISOString(),
expires: newNoticeExpiresDate.value
? dayjs(newNoticeScheduledDate.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,
level: newNoticeLevel.value.id,
dismissable: newNoticeDismissable.value,
announce_at: newNoticeScheduledDate.value ?? dayjs().toISOString(),
expires: newNoticeExpiresDate.value,
},
}).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>

View File

@ -1,5 +1,19 @@
<template>
<div class="contents">
<div
v-if="serverData?.notices && serverData.notices.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 serverData?.notices"
:key="`notice-${notice.id}`"
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
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"
@ -398,11 +412,14 @@ 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 { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
import { usePyroConsole } from "~/store/console.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);
@ -927,6 +944,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") {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,73 @@
<template>
<Admonition :type="NOTICE_TYPE[props.level]">
<template #header>
{{ formatMessage(heading) }}
</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'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'dismiss'): void
}>()
const props = withDefaults(
defineProps<{
level: string
message: string
dismissable: boolean
preview?: boolean
}>(),
{
preview: false,
},
)
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>

View File

@ -30,6 +30,7 @@ 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'

View File

@ -404,6 +404,30 @@
"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.warn.name": {
"defaultMessage": "Warning"
},
"servers.notice.undismissable": {
"defaultMessage": "Undismissable"
},
"settings.account.title": {
"defaultMessage": "Account and security"
},

View File

@ -0,0 +1,63 @@
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)',
},
},
}
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
}

View File

@ -252,3 +252,21 @@ export type Report = {
created: string
body: string
}
export type ServerNotice = {
id: number
message: string
level: string
dismissable: boolean
announce_at: string
expires: string
assigned: {
kind: 'server' | 'node'
id: string
name: string
}[]
dismissed_by: {
server: string
dismissed_on: string
}[]
}