Merge commit '74cf3f076eff43755bb4bef62f1c1bb3fc0e6c2a' into feature-clean

This commit is contained in:
2025-05-26 17:59:09 +03:00
497 changed files with 15033 additions and 9421 deletions

View File

@@ -638,6 +638,7 @@
shown: !isMember,
},
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
]"
aria-label="More options"
:dropdown-id="`${baseId}-more-options`"
@@ -659,6 +660,10 @@
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
Copy permanent link
</template>
</OverflowMenu>
</ButtonStyled>
</template>
@@ -866,6 +871,7 @@ import {
ProjectSidebarDetails,
ProjectSidebarLinks,
ScrollablePanel,
useRelativeTime,
} from "@modrinth/ui";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
@@ -888,6 +894,7 @@ import { reportProject } from "~/utils/report-helpers.ts";
const data = useNuxtApp();
const route = useNativeRoute();
const config = useRuntimeConfig();
const auth = await useAuth();
const user = await useUser();
@@ -1458,6 +1465,10 @@ async function copyId() {
await navigator.clipboard.writeText(project.value.id);
}
async function copyPermalink() {
await navigator.clipboard.writeText(`${config.public.siteUrl}/project/${project.value.id}`);
}
const collapsedChecklist = ref(false);
const showModerationChecklist = ref(false);

View File

@@ -77,7 +77,7 @@
This is a private conversation thread with the Modrinth moderators. They may message you
with issues concerning this project. This thread is only checked when you submit your
project for review. For additional inquiries, contact
<a href="https://support.modrinth.com">Modrinth support</a>.
<a href="https://support.modrinth.com">Modrinth Support</a>.
</p>
<ConversationThread
v-if="thread"

View File

@@ -1,6 +1,6 @@
<template>
<div>
<ModalConfirm
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to delete this project?"
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
@@ -242,8 +242,8 @@
import { formatProjectStatus } from "@modrinth/utils";
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
import { Multiselect } from "vue-multiselect";
import { ConfirmModal } from "@modrinth/ui";
import Avatar from "~/components/ui/Avatar.vue";
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
import FileInput from "~/components/ui/FileInput.vue";
const props = defineProps({

View File

@@ -1,6 +1,6 @@
<template>
<div>
<ModalConfirm
<ConfirmModal
ref="modal_remove"
title="Are you sure you want to remove this project from the organization?"
description="If you proceed, this project will no longer be managed by the organization."
@@ -530,8 +530,7 @@ import {
OrganizationIcon,
CrownIcon,
} from "@modrinth/assets";
import { Avatar, Badge, Card, Checkbox } from "@modrinth/ui";
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
import { Avatar, Badge, Card, Checkbox, ConfirmModal } from "@modrinth/ui";
import { removeSelfFromTeam } from "~/helpers/teams.js";
const props = defineProps({

View File

@@ -541,7 +541,6 @@
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose loaders..."
/>
@@ -566,7 +565,6 @@
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
:custom-label="(version) => version"
placeholder="Choose versions..."
@@ -622,7 +620,7 @@
<CopyCode :text="version.id" />
</div>
<div v-if="!isEditing && flags.developerMode">
<h4>Modrinth Maven</h4>
<h4>Maven coordinates</h4>
<div class="maven-section">
<CopyCode :text="`maven.modrinth:${project.id}:${version.id}`" />
</div>
@@ -1555,6 +1553,10 @@ export default defineNuxtComponent({
display: flex;
align-items: center;
gap: 0.5rem;
button {
max-width: 100%;
}
}
.team-member {

View File

@@ -169,7 +169,7 @@
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Modrinth Maven
Copy Maven coordinates
</template>
</OverflowMenu>
</ButtonStyled>

View File

@@ -92,7 +92,7 @@
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
{{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
dayjs(subscription.created).fromNow()
formatRelativeTime(subscription.created)
}})
</div>
</div>
@@ -151,7 +151,7 @@
</span>
<span class="text-sm text-secondary">
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ dayjs(charge.due).fromNow() }}) </span>
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<div
v-if="flags.developerMode"
@@ -196,7 +196,15 @@
</div>
</template>
<script setup>
import { Avatar, ButtonStyled, CopyCode, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
import {
Avatar,
ButtonStyled,
CopyCode,
DropdownSelect,
NewModal,
Toggle,
useRelativeTime,
} from "@modrinth/ui";
import { formatCategory, formatPrice } from "@modrinth/utils";
import {
CheckIcon,
@@ -215,7 +223,9 @@ const flags = useFeatureFlags();
const route = useRoute();
const data = useNuxtApp();
const vintl = useVIntl();
const { formatMessage } = vintl;
const formatRelativeTime = useRelativeTime();
const messages = defineMessages({
userNotFoundError: {

View File

@@ -156,7 +156,7 @@
<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()
formatRelativeTime(notice.announce_at)
}})
</span>
<template v-else> Never begins </template>
@@ -166,7 +166,7 @@
v-if="notice.expires"
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
>
{{ dayjs(notice.expires).fromNow() }}
{{ formatRelativeTime(notice.expires) }}
</span>
<template v-else> Never expires </template>
</div>
@@ -267,6 +267,7 @@ import {
NewModal,
TeleportDropdownMenu,
Toggle,
useRelativeTime,
} from "@modrinth/ui";
import { SettingsIcon, PlusIcon, SaveIcon, TrashIcon, EditIcon, XIcon } from "@modrinth/assets";
import dayjs from "dayjs";
@@ -278,6 +279,8 @@ import { usePyroFetch } from "~/composables/pyroFetch.ts";
import AssignNoticeModal from "~/components/ui/servers/notice/AssignNoticeModal.vue";
const { formatMessage } = useVIntl();
const formatRelativeTime = useRelativeTime();
const app = useNuxtApp() as unknown as { $notify: any };
const notices = ref<ServerNoticeType[]>([]);

View File

@@ -19,7 +19,6 @@ import Checkbox from "~/components/ui/Checkbox.vue";
import { homePageProjects } from "~/generated/state.json";
const os = ref(null);
const macValue = ref(null);
const downloadWindows = ref(null);
const downloadLinux = ref(null);
const downloadSection = ref(null);
@@ -31,8 +30,7 @@ const linuxLinks = {
thirdParty: "https://support.modrinth.com/en/articles/9298760",
};
const macLinks = {
appleSilicon: null,
intel: null,
universal: null,
};
let downloadLauncher;
@@ -53,8 +51,7 @@ const [{ data: launcherUpdates }] = await Promise.all([
),
]);
macLinks.appleSilicon = launcherUpdates.value.platforms["darwin-aarch64"].install_urls[0];
macLinks.intel = launcherUpdates.value.platforms["darwin-x86_64"].install_urls[0];
macLinks.universal = launcherUpdates.value.platforms["darwin-aarch64"].install_urls[0];
windowsLink.value = launcherUpdates.value.platforms["windows-x86_64"].install_urls[0];
linuxLinks.appImage = launcherUpdates.value.platforms["linux-x86_64"].install_urls[1];
linuxLinks.deb = launcherUpdates.value.platforms["linux-x86_64"].install_urls[0];
@@ -85,24 +82,6 @@ onMounted(() => {
}
});
watch(macValue, () => {
if (macValue.value === "Download for Apple Silicon") {
const link = document.createElement("a");
link.href = macLinks.appleSilicon;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else if (macValue.value === "Download for Intel") {
const link = document.createElement("a");
link.href = macLinks.intel;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
});
const scrollToSection = () => {
nextTick(() => {
window.scrollTo({
@@ -834,13 +813,9 @@ useSeoMeta({
Mac
</div>
<div class="description apple">
<a :href="macLinks.appleSilicon" download="">
<a :href="macLinks.universal" download="">
<DownloadIcon />
<span> Download for Apple Silicon </span>
</a>
<a :href="macLinks.intel" download="">
<DownloadIcon />
<span> Download for Intel </span>
<span> Download the beta </span>
</a>
</div>
</div>

View File

@@ -1,10 +1,20 @@
<template>
<div>
<h1>{{ formatMessage(messages.welcomeLongTitle) }}</h1>
<div class="welcome-box has-bot">
<img :src="WavingRinthbot" alt="Waving Modrinth Bot" class="welcome-box__waving-bot" />
<div class="welcome-box__top-glow" />
<div class="welcome-box__body">
<h1 class="welcome-box__title">
{{ formatMessage(messages.welcomeLongTitle) }}
</h1>
<section class="auth-form">
<p>
{{ formatMessage(messages.welcomeDescription) }}
<p class="welcome-box__subtitle">
<IntlFormatted :message-id="messages.welcomeDescription">
<template #bold="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
</p>
<Checkbox
@@ -14,11 +24,12 @@
:description="formatMessage(messages.subscribeCheckbox)"
/>
<button class="btn btn-primary continue-btn centered-btn" @click="continueSignUp">
{{ formatMessage(commonMessages.continueButton) }} <RightArrowIcon />
<button class="btn btn-primary centered-btn" @click="continueSignUp">
{{ formatMessage(commonMessages.continueButton) }}
<RightArrowIcon />
</button>
<p>
<p class="tos-text">
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
@@ -32,12 +43,15 @@
</template>
</IntlFormatted>
</p>
</section>
</div>
</div>
</template>
<script setup>
import { Checkbox, commonMessages } from "@modrinth/ui";
import { RightArrowIcon } from "@modrinth/assets";
import { RightArrowIcon, WavingRinthbot } from "@modrinth/assets";
const route = useRoute();
const { formatMessage } = useVIntl();
@@ -54,7 +68,7 @@ const messages = defineMessages({
welcomeDescription: {
id: "auth.welcome.description",
defaultMessage:
"Thank you for creating an account. You can now follow and create projects, receive updates about your favorite projects, and more!",
"Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods.",
},
welcomeLongTitle: {
id: "auth.welcome.long-title",
@@ -72,20 +86,18 @@ useHead({
const subscribe = ref(true);
async function continueSignUp() {
const route = useRoute();
onMounted(async () => {
await useAuth(route.query.authToken);
await useUser();
});
async function continueSignUp() {
if (subscribe.value) {
try {
await useBaseFetch("auth/email/subscribe", {
method: "POST",
});
} catch {
/* empty */
}
} catch {}
}
if (route.query.redirect) {
@@ -95,3 +107,84 @@ async function continueSignUp() {
}
}
</script>
<style lang="scss" scoped>
.welcome-box {
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-lg);
padding: 1.75rem 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
box-shadow: var(--shadow-card);
position: relative;
&.has-bot {
margin-block: 120px;
}
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: var(--weight-bold);
&:hover,
&:focus {
filter: brightness(1.125);
text-decoration: underline;
}
}
&__waving-bot {
--bot-height: 112px;
position: absolute;
top: calc(-1 * var(--bot-height));
right: 5rem;
height: var(--bot-height);
width: auto;
@media (max-width: 768px) {
--bot-height: 70px;
right: 2rem;
}
}
&__top-glow {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px;
opacity: 0.4;
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
}
&__body {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
&__title {
font-size: var(--text-32);
font-weight: var(--weight-extrabold);
margin: 0;
}
&__subtitle {
font-size: var(--text-18);
}
.tos-text {
font-size: var(--text-14);
line-height: 1.5;
}
}
</style>

View File

@@ -391,6 +391,7 @@ import {
DropdownSelect,
FileInput,
PopoutMenu,
useRelativeTime,
} from "@modrinth/ui";
import { isAdmin } from "@modrinth/utils";

View File

@@ -99,7 +99,7 @@
import { ChevronRightIcon, HistoryIcon } from "@modrinth/assets";
import Avatar from "~/components/ui/Avatar.vue";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import { fetchExtraNotificationData, groupNotifications } from "~/helpers/notifications.js";
import { fetchExtraNotificationData, groupNotifications } from "~/helpers/notifications.ts";
useHead({
title: "Dashboard - Modrinth",

View File

@@ -12,7 +12,7 @@
<h2 v-else class="text-2xl">Notifications</h2>
</div>
<template v-if="!history">
<Button v-if="hasRead" @click="updateRoute()">
<Button v-if="data.hasRead" @click="updateRoute()">
<HistoryIcon />
View history
</Button>
@@ -60,7 +60,7 @@ import {
fetchExtraNotificationData,
groupNotifications,
markAsRead,
} from "~/helpers/notifications.js";
} from "~/helpers/notifications.ts";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import Pagination from "~/components/ui/Pagination.vue";
@@ -70,93 +70,69 @@ useHead({
});
const auth = await useAuth();
const route = useNativeRoute();
const router = useNativeRouter();
const history = computed(() => {
return route.name === "dashboard-notifications-history";
});
const history = computed(() => route.name === "dashboard-notifications-history");
const selectedType = ref("all");
const page = ref(1);
const perPage = ref(50);
const { data, pending, error, refresh } = await useAsyncData(
async () => {
const pageNum = page.value - 1;
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`);
const showRead = history.value;
const hasRead = notifications.some((notif) => notif.read);
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`);
const types = [
...new Set(
notifications
.filter((notification) => {
return showRead || !notification.read;
})
.map((notification) => notification.type),
),
const typesInFeed = [
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
];
const filteredNotifications = notifications.filter(
(notification) =>
(selectedType.value === "all" || notification.type === selectedType.value) &&
(showRead || !notification.read),
const filtered = notifications.filter(
(n) =>
(selectedType.value === "all" || n.type === selectedType.value) && (showRead || !n.read),
);
const pages = Math.ceil(filteredNotifications.length / perPage.value);
const pages = Math.max(1, Math.ceil(filtered.length / perPage.value));
return fetchExtraNotificationData(
filteredNotifications.slice(pageNum * perPage.value, perPage.value + pageNum * perPage.value),
).then((notifications) => {
return {
notifications,
types: types.length > 1 ? ["all", ...types] : types,
pages,
hasRead,
};
});
filtered.slice(pageNum * perPage.value, pageNum * perPage.value + perPage.value),
).then((notifs) => ({
notifications: notifs,
notifTypes: typesInFeed.length > 1 ? ["all", ...typesInFeed] : typesInFeed,
pages,
hasRead: notifications.some((n) => n.read),
}));
},
{ watch: [page, history, selectedType] },
);
const notifications = computed(() => {
if (data.value === null) {
return [];
}
return groupNotifications(data.value.notifications, history.value);
});
const notifTypes = computed(() => data.value.types);
const pages = computed(() => data.value.pages);
const hasRead = computed(() => data.value.hasRead);
const notifications = computed(() =>
data.value ? groupNotifications(data.value.notifications, history.value) : [],
);
const notifTypes = computed(() => data.value?.notifTypes || []);
const pages = computed(() => data.value?.pages ?? 1);
function updateRoute() {
if (history.value) {
router.push("/dashboard/notifications");
} else {
router.push("/dashboard/notifications/history");
}
router.push(history.value ? "/dashboard/notifications" : "/dashboard/notifications/history");
selectedType.value = "all";
page.value = 1;
}
async function readAll() {
const ids = notifications.value.flatMap((notification) => [
notification.id,
...(notification.grouped_notifs ? notification.grouped_notifs.map((notif) => notif.id) : []),
const ids = notifications.value.flatMap((n) => [
n.id,
...(n.grouped_notifs ? n.grouped_notifs.map((g) => g.id) : []),
]);
const updateNotifs = await markAsRead(ids);
allNotifs.value = updateNotifs(allNotifs.value);
await markAsRead(ids);
await refresh();
}
function changePage(newPage) {
page.value = newPage;
if (import.meta.client) {
window.scrollTo({ top: 0, behavior: "smooth" });
}
if (import.meta.client) window.scrollTo({ top: 0, behavior: "smooth" });
}
</script>
<style lang="scss" scoped>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useRelativeTime } from "@modrinth/ui";
const vintl = useVIntl();
const { formatMessage } = vintl;

View File

@@ -185,7 +185,7 @@
<CalendarIcon aria-hidden="true" />
<span>
Received
{{ fromNow(notification.date_modified) }}
{{ formatRelativeTime(notification.date_modified) }}
</span>
</div>
</div>
@@ -527,7 +527,7 @@
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import { ButtonStyled } from "@modrinth/ui";
import { ButtonStyled, useRelativeTime } from "@modrinth/ui";
import {
CompassIcon,
LogInIcon,
@@ -544,6 +544,8 @@ import ProjectCard from "~/components/ui/ProjectCard.vue";
import { homePageProjects, homePageSearch, homePageNotifs } from "~/generated/state.json";
const formatRelativeTime = useRelativeTime();
const searchQuery = ref("leave");
const sortType = ref("relevance");

View File

@@ -52,10 +52,7 @@
>
<div class="project-title">
<div class="mobile-row">
<nuxt-link
:to="`/${project.inferred_project_type}/${project.slug}`"
class="iconified-stacked-link"
>
<nuxt-link :to="`/project/${project.id}`" class="iconified-stacked-link">
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
<span class="stacked">
<span class="title">{{ project.name }}</span>
@@ -67,7 +64,7 @@
by
<nuxt-link
v-if="project.owner"
:to="`/user/${project.owner.user.username}`"
:to="`/user/${project.owner.user.id}`"
class="iconified-link"
>
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
@@ -75,7 +72,7 @@
</nuxt-link>
<nuxt-link
v-else-if="project.org"
:to="`/organization/${project.org.slug}`"
:to="`/organization/${project.org.id}`"
class="iconified-link"
>
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
@@ -88,10 +85,7 @@
</div>
</div>
<div class="input-group">
<nuxt-link
:to="`/${project.inferred_project_type}/${project.slug}`"
class="iconified-button raised-button"
>
<nuxt-link :to="`/project/${project.id}`" class="iconified-button raised-button">
<EyeIcon />
View project
</nuxt-link>
@@ -100,7 +94,7 @@
<IssuesIcon v-if="project.age_warning" />
Submitted
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
fromNow(project.queued)
formatRelativeTime(project.queued)
}}</span>
</span>
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
@@ -109,7 +103,7 @@
</template>
<script setup>
import { Chips } from "@modrinth/ui";
import { Chips, useRelativeTime } from "@modrinth/ui";
import {
UnknownIcon,
EyeIcon,
@@ -134,6 +128,8 @@ const now = app.$dayjs();
const TIME_24H = 86400000;
const TIME_48H = TIME_24H * 2;
const formatRelativeTime = useRelativeTime();
const { data: projects } = await useAsyncData("moderation/projects?count=1000", () =>
useBaseFetch("moderation/projects?count=1000", { internal: true }),
);

View File

@@ -39,13 +39,8 @@ if (!changelogEntry.value) {
>
<ChevronLeftIcon /> View full changelog
</nuxt-link>
<Timeline fade-out-end :fade-out-start="!isFirst" :class="{ '-mt-8': !isFirst }">
<ChangelogEntry
:entry="changelogEntry"
:first="isFirst"
show-type
:class="{ 'mt-8': !isFirst }"
/>
<Timeline fade-out-end :fade-out-start="!isFirst">
<ChangelogEntry :entry="changelogEntry" :first="isFirst" show-type />
</Timeline>
</div>
</template>

View File

@@ -123,6 +123,7 @@
},
{ divider: true, shown: auth.user && currentMember },
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
]"
aria-label="More options"
>
@@ -135,6 +136,10 @@
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
@@ -287,6 +292,7 @@ const cosmetics = useCosmetics();
const route = useNativeRoute();
const tags = useTags();
const flags = useFeatureFlags();
const config = useRuntimeConfig();
let orgId = useRouteId();
@@ -502,6 +508,12 @@ const navLinks = computed(() => [
async function copyId() {
await navigator.clipboard.writeText(organization.value.id);
}
async function copyPermalink() {
await navigator.clipboard.writeText(
`${config.public.siteUrl}/organization/${organization.value.id}`,
);
}
</script>
<style scoped lang="scss">

View File

@@ -1,7 +1,7 @@
<template>
<div class="page">
<div class="experimental-styles-within flex flex-col gap-2">
<RadialHeader class="top-box mb-2 text-center" color="orange">
<RadialHeader class="top-box mb-2 flex flex-col items-center justify-center" color="orange">
<ScaleIcon class="h-12 w-12 text-brand-orange" />
<h1 class="m-3 gap-2 text-3xl font-extrabold">
{{

View File

@@ -303,7 +303,7 @@
</svg>
<h2 class="m-0 text-lg font-bold">Backups included</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Every server comes with 15 backups stored securely off-site with Backblaze.
Every server comes with 15 backups stored securely off-site.
</h3>
</div>
</div>
@@ -399,7 +399,7 @@
</summary>
<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.
GHz, paired with DDR5 memory.
</p>
</details>
<details pyro-hash="cpu-burst" class="group" :open="$route.hash === '#cpu-burst'">
@@ -480,6 +480,18 @@
plan for the content you're running on the server.
</p>
</details>
<details pyro-hash="players" class="group" :open="$route.hash === '#prices'">
<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>
What currency are the prices in?
</summary>
<p class="m-0 ml-6 leading-[160%]">
All prices are listed in United States Dollars (USD).
</p>
</details>
</div>
</div>
</div>

View File

@@ -33,26 +33,7 @@
</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>
<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>
<div
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
v-else-if="serverData?.status === 'suspended'"
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">
@@ -72,7 +53,7 @@
: "Your server has been suspended."
}}
<br />
Contact Modrinth support if you believe this is an error.
Contact Modrinth Support if you believe this is an error.
</p>
</div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
@@ -97,7 +78,7 @@
</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.
is an error, please contact Modrinth Support.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
@@ -289,7 +270,7 @@
can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth support with the information below:
If you're stuck, please contact Modrinth Support with the information below:
</li>
</ul>
<ButtonStyled>
@@ -309,7 +290,7 @@
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.
contact Modrinth Support with your server's debug information.
</div>
<div
@@ -787,6 +768,40 @@ const handleWebSocketMessage = (data: WSEvent) => {
break;
}
case "filesystem-ops": {
if (!server.fs) {
console.error("FilesystemOps received, but server.fs is not available", data.all);
break;
}
if (JSON.stringify(server.fs.ops) !== JSON.stringify(data.all)) {
server.fs.ops = data.all;
}
server.fs.queuedOps = server.fs.queuedOps.filter(
(queuedOp) => !data.all.some((x) => x.src === queuedOp.src),
);
const cancelled = data.all.filter((x) => x.state === "cancelled");
Promise.all(cancelled.map((x) => server.fs?.modifyOp(x.id, "dismiss")));
const completed = data.all.filter((x) => x.state === "done");
if (completed.length > 0) {
setTimeout(
async () =>
await Promise.all(
completed.map((x) => {
if (!server.fs?.opsQueuedForModification.includes(x.id)) {
server.fs?.opsQueuedForModification.push(x.id);
return server.fs?.modifyOp(x.id, "dismiss");
}
return Promise.resolve();
}),
),
3000,
);
}
break;
}
default:
console.warn("Unhandled WebSocket event:", data);
}

View File

@@ -56,8 +56,7 @@
</TagItem>
</div>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, securely off-site with
Backblaze.
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
</p>
</div>
<div

View File

@@ -5,6 +5,8 @@
:type="newItemType"
@create="handleCreateNewItem"
/>
<FilesUploadZipUrlModal ref="uploadZipModal" :server="server" />
<FilesUploadConflictModal ref="uploadConflictModal" @proceed="extractItem" />
<LazyUiServersFilesRenameItemModal
ref="renameItemModal"
@@ -35,9 +37,12 @@
:breadcrumb-segments="breadcrumbSegments"
:search-query="searchQuery"
:current-filter="viewFilter"
:base-id="`browse-navbar-${baseId}`"
@navigate="navigateToSegment"
@create="showCreateModal"
@upload="initiateFileUpload"
@upload-zip="() => {}"
@unzip-from-url="showUnzipFromUrlModal"
@filter="handleFilter"
@update:search-query="searchQuery = $event"
/>
@@ -46,6 +51,110 @@
:sort-desc="sortDesc"
@sort="handleSort"
/>
<div
v-for="op in ops"
:key="`fs-op-${op.op}-${op.src}`"
class="sticky top-20 z-20 grid grid-cols-[auto_1fr_auto] items-center gap-2 border-0 border-b-[1px] border-solid border-button-bg bg-table-alternateRow px-4 py-2 md:grid-cols-[auto_1fr_1fr_2fr_auto]"
>
<div>
<PackageOpenIcon class="h-5 w-5 text-secondary" />
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 md:contents">
<div class="flex items-center text-wrap break-all text-sm font-bold text-contrast">
Extracting {{ op.src.includes("https://") ? "modpack from URL" : op.src }}
</div>
<span
class="flex items-center gap-2 text-sm font-semibold"
:class="{
'text-green': op.state === 'done',
'text-red': op.state?.startsWith('fail'),
'text-orange': !op.state?.startsWith('fail') && op.state !== 'done',
}"
>
<template v-if="op.state === 'done'">
Done
<CheckIcon style="stroke-width: 3px" />
</template>
<template v-else-if="op.state?.startsWith('fail')">
Failed
<XIcon style="stroke-width: 3px" />
</template>
<template v-else-if="op.state === 'cancelled'">
<SpinnerIcon class="animate-spin" />
Cancelling
</template>
<template v-else-if="op.state === 'queued'">
<SpinnerIcon class="animate-spin" />
Queued...
</template>
<template v-else-if="op.state === 'ongoing'">
<SpinnerIcon class="animate-spin" />
Extracting...
</template>
<template v-else>
<UnknownIcon />
Unknown state: {{ op.state }}
</template>
</span>
<div class="col-span-2 flex grow flex-col gap-1 md:col-span-1 md:items-end">
<div class="text-xs font-semibold text-contrast opacity-80">
<span :class="{ invisible: 'current_file' in op && !op.current_file }">
{{
"current_file" in op
? op.current_file?.split("/")?.pop() ?? "unknown"
: "unknown"
}}
</span>
</div>
<ProgressBar
:progress="'progress' in op ? op.progress : 0"
:max="1"
:color="
op.state === 'done'
? 'green'
: op.state?.startsWith('fail')
? 'red'
: op.state === 'cancelled'
? 'gray'
: 'orange'
"
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
/>
<div
class="text-xs text-secondary opacity-80"
:class="{ invisible: 'bytes_processed' in op && !op.bytes_processed }"
>
{{ "bytes_processed" in op ? formatBytes(op.bytes_processed) : "0 B" }} extracted
</div>
</div>
</div>
<div>
<ButtonStyled circular>
<button
:disabled="!('id' in op) || !op.id"
class="radial-progress-animation-overlay"
:class="{ active: op.state === 'done' }"
@click="
() => {
op.state === 'done'
? server.fs?.modifyOp(op.id, 'dismiss')
: 'id' in op
? server.fs?.modifyOp(op.id, 'cancel')
: () => {};
}
"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<pre
v-if="flags.advancedDebugInfo"
class="markdown-body col-span-full m-0 rounded-xl bg-button-bg text-xs"
>{{ op }}</pre
>
</div>
<FilesUploadDropdown
v-if="props.server.fs"
ref="uploadDropdownRef"
@@ -55,7 +164,6 @@
@upload-complete="refreshList()"
/>
</div>
<UiServersFilesEditingNavbar
v-else
:file-name="editingFile?.name"
@@ -97,10 +205,10 @@
/>
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
</div>
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
<UiServersFileVirtualList
:items="filteredItems"
@extract="handleExtractItem"
@delete="showDeleteModal"
@rename="showRenameModal"
@download="downloadFile"
@@ -159,10 +267,32 @@
<script setup lang="ts">
import { useInfiniteScroll } from "@vueuse/core";
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
import {
UnknownIcon,
XIcon,
SpinnerIcon,
PackageOpenIcon,
CheckIcon,
UploadIcon,
FolderOpenIcon,
} from "@modrinth/assets";
import { computed } from "vue";
import { ButtonStyled, ProgressBar } from "@modrinth/ui";
import { formatBytes } from "@modrinth/utils";
import {
type DirectoryResponse,
type DirectoryItem,
type Server,
handleError,
} from "~/composables/pyroServers.ts";
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
import type { FilesystemOp, FSQueuedOp } from "~/types/servers.ts";
import FilesUploadZipUrlModal from "~/components/ui/servers/FilesUploadZipUrlModal.vue";
import FilesUploadConflictModal from "~/components/ui/servers/FilesUploadConflictModal.vue";
const flags = useFeatureFlags();
const baseId = useId();
interface BaseOperation {
type: "move" | "rename";
@@ -217,6 +347,8 @@ const createItemModal = ref();
const renameItemModal = ref();
const moveItemModal = ref();
const deleteItemModal = ref();
const uploadZipModal = ref();
const uploadConflictModal = ref();
const newItemType = ref<"file" | "directory">("file");
const selectedItem = ref<any>(null);
@@ -449,6 +581,33 @@ const handleRenameItem = async (newName: string) => {
}
};
const extractItem = async (path: string) => {
try {
await props.server.fs?.extractFile(path, true, false);
} catch (error) {
console.error("Error extracting item:", error);
handleError(error);
}
};
const handleExtractItem = async (item: { name: string; type: string; path: string }) => {
try {
const dry = await props.server.fs?.extractFile(item.path, true, true, true);
if (dry) {
if (dry.conflicting_files.length === 0) {
await extractItem(item.path);
} else {
uploadConflictModal.value.show(item.path, dry.conflicting_files);
}
} else {
handleError(new Error("Error running dry run"));
}
} catch (error) {
console.error("Error extracting item:", error);
handleError(error);
}
};
const handleMoveItem = async (destination: string) => {
try {
const itemType = selectedItem.value.type;
@@ -536,6 +695,10 @@ const showCreateModal = (type: "file" | "directory") => {
createItemModal.value?.show();
};
const showUnzipFromUrlModal = (cf: boolean) => {
uploadZipModal.value?.show(cf);
};
const showRenameModal = (item: any) => {
selectedItem.value = item;
renameItemModal.value?.show(item);
@@ -760,6 +923,8 @@ onMounted(async () => {
redoLastOperation();
}
});
props.server.fs?.clearQueuedOps();
});
onUnmounted(() => {
@@ -768,6 +933,22 @@ onUnmounted(() => {
document.removeEventListener("keydown", () => {});
});
const clientSideQueued = computed<FSQueuedOp[]>(() => props.server.fs?.queuedOps ?? []);
type QueuedOpWithState = FSQueuedOp & { state: "queued" };
const ops = computed<(QueuedOpWithState | FilesystemOp)[]>(() => [
...clientSideQueued.value.map((x) => ({ ...x, state: "queued" }) satisfies QueuedOpWithState),
...(props.server.fs?.ops ?? []),
]);
watch(
() => props.server.fs?.ops,
() => {
refreshList();
},
);
watch(
() => route.query,
async (newQuery) => {
@@ -984,4 +1165,43 @@ const onScroll = () => {
transform: scale(1);
opacity: 1;
}
.radial-progress-animation-overlay {
position: relative;
}
@property --_radial-percentage {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.radial-progress-animation-overlay.active::before {
animation: radial-progress 3s linear forwards;
}
.radial-progress-animation-overlay::before {
content: "";
inset: -2px;
position: absolute;
border-radius: 50%;
box-sizing: content-box;
border: 2px solid var(--color-button-bg);
filter: brightness(var(--hover-brightness));
mask-image: conic-gradient(
black 0%,
black var(--_radial-percentage),
transparent var(--_radial-percentage),
transparent 100%
);
}
@keyframes radial-progress {
from {
--_radial-percentage: 0%;
}
to {
--_radial-percentage: 100%;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<ModalConfirm
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to delete your account?"
description="This will **immediately delete all of your user data and follows**. This will not delete your projects. Deleting your account cannot be reversed.<br><br>If you need help with your account, get support on the [Modrinth Discord](https://discord.modrinth.com)."
@@ -421,6 +421,7 @@ import {
DownloadIcon,
} from "@modrinth/assets";
import QrcodeVue from "qrcode.vue";
import { ConfirmModal } from "@modrinth/ui";
import GitHubIcon from "assets/icons/auth/sso-github.svg";
import MicrosoftIcon from "assets/icons/auth/sso-microsoft.svg";
import GoogleIcon from "assets/icons/auth/sso-google.svg";
@@ -428,7 +429,6 @@ import SteamIcon from "assets/icons/auth/sso-steam.svg";
import DiscordIcon from "assets/icons/auth/sso-discord.svg";
import KeyIcon from "assets/icons/auth/key.svg";
import GitLabIcon from "assets/icons/auth/sso-gitlab.svg";
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
import Modal from "~/components/ui/Modal.vue";
useHead({

View File

@@ -64,6 +64,21 @@
</template>
</span>
<template v-if="midasCharge">
<span
v-if="
midasCharge.status === 'open' && midasCharge.subscription_interval === 'monthly'
"
class="text-sm text-purple"
>
Save
{{
formatPrice(
vintl.locale,
midasCharge.amount * 12 - oppositePrice,
midasCharge.currency_code,
)
}}/year by switching to yearly billing!
</span>
<span class="text-sm text-secondary">
Since {{ $dayjs(midasSubscription.created).format("MMMM D, YYYY") }}
</span>
@@ -118,19 +133,46 @@
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-else-if="midasCharge && midasCharge.status !== 'cancelled'">
<button
class="ml-auto"
@click="
() => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
}
"
<div
v-else-if="midasCharge && midasCharge.status !== 'cancelled'"
class="ml-auto flex gap-2"
>
<ButtonStyled>
<button
:disabled="changingInterval"
@click="
() => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
}
"
>
<XIcon /> Cancel
</button>
</ButtonStyled>
<ButtonStyled
:color="midasCharge.subscription_interval === 'yearly' ? 'standard' : 'purple'"
color-fill="text"
>
<XIcon /> Cancel
</button>
</ButtonStyled>
<button
v-tooltip="
midasCharge.subscription_interval === 'yearly'
? `Monthly billing will cost you an additional ${formatPrice(
vintl.locale,
oppositePrice * 12 - midasCharge.amount,
midasCharge.currency_code,
)} per year`
: undefined
"
:disabled="changingInterval"
@click="switchMidasInterval(oppositeInterval)"
>
<SpinnerIcon v-if="changingInterval" class="animate-spin" />
<TransferIcon v-else /> {{ changingInterval ? "Switching" : "Switch" }} to
{{ oppositeInterval }}
</button>
</ButtonStyled>
</div>
<ButtonStyled
v-else-if="midasCharge && midasCharge.status === 'cancelled'"
color="purple"
@@ -178,8 +220,12 @@
/>
<div v-else class="w-fit">
<p>
A linked server couldn't be found with this subscription. It may have been deleted
or suspended. Please contact Modrinth support with the following information:
A linked server couldn't be found for this subscription. There are a few possible
explanations for this. If you just purchased your server, this is normal. It could
take up to an hour for your server to be provisioned. Otherwise, if you purchased
this server a while ago, it has likely since been suspended. If this is not what
you were expecting, please contact Modrinth Support with the following
information:
</p>
<div class="flex w-full flex-col gap-2">
<CopyCode
@@ -288,7 +334,7 @@
getPyroCharge(subscription).status !== 'failed'
"
>
<button @click="showPyroCancelModal(subscription.id)">
<button @click="showCancellationSurvey(subscription)">
<XIcon />
Cancel
</button>
@@ -315,7 +361,14 @@
"
color="green"
>
<button @click="resubscribePyro(subscription.id)">
<button
@click="
resubscribePyro(
subscription.id,
$dayjs(getPyroCharge(subscription).due).isBefore($dayjs()),
)
"
>
Resubscribe <RightArrowIcon />
</button>
</ButtonStyled>
@@ -547,6 +600,8 @@ import {
} from "@modrinth/ui";
import {
PlusIcon,
TransferIcon,
SpinnerIcon,
ArrowBigUpDashIcon,
XIcon,
CardIcon,
@@ -750,6 +805,13 @@ const midasCharge = computed(() =>
: null,
);
const oppositePrice = computed(() =>
midasSubscription.value
? midasProduct.value?.prices?.find((price) => price.id === midasSubscription.value.price_id)
?.prices?.intervals?.[oppositeInterval.value]
: undefined,
);
const pyroSubscriptions = computed(() => {
const pyroSubs = subscriptions.value?.filter((s) => s?.metadata?.type === "pyro") || [];
const servers = serversData.value?.servers || [];
@@ -847,6 +909,31 @@ async function submit() {
const removePaymentMethodIndex = ref();
const changingInterval = ref(false);
const oppositeInterval = computed(() =>
midasCharge.value?.subscription_interval === "yearly" ? "monthly" : "yearly",
);
async function switchMidasInterval(interval) {
changingInterval.value = true;
startLoading();
try {
await useBaseFetch(`billing/subscription/${midasSubscription.value.id}`, {
internal: true,
method: "PATCH",
body: {
interval,
},
});
await refresh();
} catch (error) {
console.error("Error switching Modrinth+ payment interval:", error);
}
stopLoading();
changingInterval.value = false;
}
async function editPaymentMethod(index, primary) {
startLoading();
try {
@@ -946,15 +1033,6 @@ const getProductPrice = (product, interval) => {
const modalCancel = ref(null);
const showPyroCancelModal = (subscriptionId) => {
cancelSubscriptionId.value = subscriptionId;
if (modalCancel.value) {
modalCancel.value.show();
} else {
console.error("modalCancel ref is undefined");
}
};
const pyroPurchaseModal = ref();
const currentSubscription = ref(null);
const currentProduct = ref(null);
@@ -1027,7 +1105,7 @@ async function fetchCapacityStatuses(serverId, product) {
}
}
const resubscribePyro = async (subscriptionId) => {
const resubscribePyro = async (subscriptionId, wasSuspended) => {
try {
await useBaseFetch(`billing/subscription/${subscriptionId}`, {
internal: true,
@@ -1037,6 +1115,21 @@ const resubscribePyro = async (subscriptionId) => {
},
});
await refresh();
if (wasSuspended) {
data.$notify({
group: "main",
title: "Resubscription request submitted",
text: "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.",
type: "success",
});
} else {
data.$notify({
group: "main",
title: "Success",
text: "Server subscription resubscribed successfully",
type: "success",
});
}
} catch {
data.$notify({
group: "main",
@@ -1057,4 +1150,66 @@ const refresh = async () => {
refreshServers(),
]);
};
function showCancellationSurvey(subscription) {
if (!subscription) {
console.warn("No survey notice to open");
return;
}
const product = getPyroProduct(subscription);
const priceObj = product?.prices?.find((x) => x.id === subscription.price_id);
const price = priceObj?.prices?.intervals?.[subscription.interval];
const currency = priceObj?.currency_code;
const popupOptions = {
layout: "modal",
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
username: auth.value?.user?.username,
user_id: auth.value?.user?.id,
user_email: auth.value?.user?.email,
subscription_id: subscription.id,
price_id: subscription.price_id,
interval: subscription.interval,
started: subscription.created,
plan_ram: product?.metadata.ram / 1024,
plan_cpu: product?.metadata.cpu,
price: price ? `${price / 100}` : "unknown",
currency: currency ?? "unknown",
},
onOpen: () => console.log(`Opened cancellation survey for: ${subscription.id}`),
onClose: () => console.log(`Closed cancellation survey for: ${subscription.id}`),
onSubmit: (payload) => {
console.log("Form submitted, cancelling server.", payload);
cancelSubscription(subscription.id, true);
},
};
const formId = "mOr7lM";
try {
if (window.Tally?.openPopup) {
console.log(
`Opening Tally popup for servers subscription ${subscription.id} (form ID: ${formId})`,
);
window.Tally.openPopup(formId, popupOptions);
} else {
console.warn("Tally script not yet loaded");
}
} catch (e) {
console.error("Error opening Tally popup:", e);
}
}
useHead({
script: [
{
src: "https://tally.so/widgets/embed.js",
defer: true,
},
],
});
</script>

View File

@@ -203,7 +203,13 @@
</template>
<script setup>
import { PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from "@modrinth/assets";
import { Checkbox, ConfirmModal, commonSettingsMessages, commonMessages } from "@modrinth/ui";
import {
Checkbox,
ConfirmModal,
commonSettingsMessages,
commonMessages,
useRelativeTime,
} from "@modrinth/ui";
import {
hasScope,

View File

@@ -57,7 +57,7 @@
</template>
<script setup>
import { XIcon } from "@modrinth/assets";
import { commonMessages, commonSettingsMessages } from "@modrinth/ui";
import { commonMessages, commonSettingsMessages, useRelativeTime } from "@modrinth/ui";
definePageMeta({
middleware: "auth",

View File

@@ -125,6 +125,7 @@
shown: auth.user?.id !== user.id,
},
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
{
id: 'open-billing',
action: () => navigateTo(`/admin/billing/${user.id}`),
@@ -151,6 +152,10 @@
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
<template #open-billing>
<CurrencyIcon aria-hidden="true" />
{{ formatMessage(messages.billingButton) }}
@@ -355,6 +360,7 @@ import {
ContentPageHeader,
commonMessages,
NewModal,
useRelativeTime,
} from "@modrinth/ui";
import { isStaff } from "~/helpers/users.js";
import NavTabs from "~/components/ui/NavTabs.vue";
@@ -381,6 +387,7 @@ const auth = await useAuth();
const cosmetics = useCosmetics();
const tags = useTags();
const flags = useFeatureFlags();
const config = useRuntimeConfig();
const vintl = useVIntl();
const { formatMessage } = vintl;
@@ -616,6 +623,10 @@ async function copyId() {
await navigator.clipboard.writeText(user.value.id);
}
async function copyPermalink() {
await navigator.clipboard.writeText(`${config.public.siteUrl}/user/${user.value.id}`);
}
const navLinks = computed(() => [
{
label: formatMessage(commonMessages.allProjectType),