You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '037cc86c1f520d8e89e721a631c9163d01c61070' into feature-clean
This commit is contained in:
@@ -184,7 +184,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewModal ref="downloadModal">
|
||||
<NewModal
|
||||
ref="downloadModal"
|
||||
:on-show="
|
||||
() => {
|
||||
navigateTo({ query: route.query, hash: '#download' });
|
||||
}
|
||||
"
|
||||
:on-hide="
|
||||
() => {
|
||||
navigateTo({ query: route.query, hash: '' });
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||
<div class="truncate text-lg font-extrabold text-contrast">
|
||||
@@ -275,7 +287,7 @@
|
||||
</div>
|
||||
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
|
||||
<ButtonStyled
|
||||
v-for="version in project.game_versions
|
||||
v-for="gameVersion in project.game_versions
|
||||
.filter(
|
||||
(x) =>
|
||||
(versionFilter && x.includes(versionFilter)) ||
|
||||
@@ -284,30 +296,39 @@
|
||||
)
|
||||
.slice()
|
||||
.reverse()"
|
||||
:key="version"
|
||||
:color="currentGameVersion === version ? 'brand' : 'standard'"
|
||||
:key="gameVersion"
|
||||
:color="currentGameVersion === gameVersion ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!possibleGameVersions.includes(version)
|
||||
? `${project.title} does not support ${version} for ${formatCategory(currentPlatform)}`
|
||||
!possibleGameVersions.includes(gameVersion)
|
||||
? `${project.title} does not support ${gameVersion} for ${formatCategory(currentPlatform)}`
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'looks-disabled !text-brand-red': !possibleGameVersions.includes(version),
|
||||
'looks-disabled !text-brand-red': !possibleGameVersions.includes(gameVersion),
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
userSelectedGameVersion = version;
|
||||
userSelectedGameVersion = gameVersion;
|
||||
gameVersionAccordion.close();
|
||||
if (!currentPlatform && platformAccordion) {
|
||||
platformAccordion.open();
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
|
||||
...(userSelectedPlatform && { loader: userSelectedPlatform }),
|
||||
},
|
||||
hash: route.hash,
|
||||
});
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ version }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === version" />
|
||||
{{ gameVersion }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
@@ -379,6 +400,15 @@
|
||||
if (!currentGameVersion && gameVersionAccordion) {
|
||||
gameVersionAccordion.open();
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
|
||||
...(userSelectedPlatform && { loader: userSelectedPlatform }),
|
||||
},
|
||||
hash: route.hash,
|
||||
});
|
||||
}
|
||||
"
|
||||
>
|
||||
@@ -506,7 +536,7 @@
|
||||
placeholder="Search collections..."
|
||||
class="search-input menu-search"
|
||||
/>
|
||||
<div v-if="collections.length > 0" class="collections-list">
|
||||
<div v-if="collections.length > 0" class="collections-list text-primary">
|
||||
<Checkbox
|
||||
v-for="option in collections
|
||||
.slice()
|
||||
@@ -601,7 +631,7 @@
|
||||
auth.user ? reportProject(project.id) : navigateTo('/auth/sign-in'),
|
||||
color: 'red',
|
||||
hoverOnly: true,
|
||||
shown: !currentMember,
|
||||
shown: !isMember,
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
]"
|
||||
@@ -772,6 +802,7 @@
|
||||
:reset-members="resetMembers"
|
||||
:route="route"
|
||||
@on-download="triggerDownloadAnimation"
|
||||
@delete-version="deleteVersion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -785,31 +816,31 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ScaleIcon,
|
||||
AlignLeftIcon as DescriptionIcon,
|
||||
BookmarkIcon,
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
ChartIcon,
|
||||
CheckIcon,
|
||||
ClipboardCopyIcon,
|
||||
CopyrightIcon,
|
||||
AlignLeftIcon as DescriptionIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
ImageIcon as GalleryIcon,
|
||||
GameIcon,
|
||||
HeartIcon,
|
||||
ImageIcon as GalleryIcon,
|
||||
InfoIcon,
|
||||
LinkIcon as LinksIcon,
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -818,32 +849,33 @@ import {
|
||||
NewModal,
|
||||
OverflowMenu,
|
||||
PopoutMenu,
|
||||
ScrollablePanel,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectHeader,
|
||||
ProjectSidebarCompatibility,
|
||||
ProjectSidebarCreators,
|
||||
ProjectSidebarLinks,
|
||||
ProjectSidebarDetails,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectSidebarLinks,
|
||||
ScrollablePanel,
|
||||
} from "@modrinth/ui";
|
||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||
import { navigateTo } from "#app";
|
||||
import dayjs from "dayjs";
|
||||
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
@@ -1172,6 +1204,10 @@ const members = computed(() => {
|
||||
return owner ? [owner, ...rest] : rest;
|
||||
});
|
||||
|
||||
const isMember = computed(
|
||||
() => auth.value.user && allMembers.value.some((x) => x.user.id === auth.value.user.id),
|
||||
);
|
||||
|
||||
const currentMember = computed(() => {
|
||||
let val = auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null;
|
||||
|
||||
@@ -1247,6 +1283,23 @@ if (!route.name.startsWith("type-id-settings")) {
|
||||
|
||||
const onUserCollectProject = useClientTry(userCollectProject);
|
||||
|
||||
const { version, loader } = route.query;
|
||||
if (version !== undefined && project.value.game_versions.includes(version)) {
|
||||
userSelectedGameVersion.value = version;
|
||||
}
|
||||
if (loader !== undefined && project.value.loaders.includes(loader)) {
|
||||
userSelectedPlatform.value = loader;
|
||||
}
|
||||
|
||||
watch(downloadModal, (modal) => {
|
||||
if (!modal) return;
|
||||
|
||||
// route.hash returns everything in the hash string, including the # itself
|
||||
if (route.hash === "#download") {
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
|
||||
async function setProcessing() {
|
||||
startLoading();
|
||||
|
||||
@@ -1403,6 +1456,20 @@ function onDownload(event) {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function deleteVersion(id) {
|
||||
if (!id) return;
|
||||
|
||||
startLoading();
|
||||
|
||||
await useBaseFetch(`version/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
versions.value = versions.value.filter((x) => x.id !== id);
|
||||
|
||||
stopLoading();
|
||||
}
|
||||
|
||||
const navLinks = computed(() => {
|
||||
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`;
|
||||
|
||||
|
||||
@@ -381,6 +381,7 @@
|
||||
/>
|
||||
<ButtonStyled v-if="isEditing">
|
||||
<button
|
||||
class="raised-button"
|
||||
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
|
||||
@click="
|
||||
() => {
|
||||
@@ -821,6 +822,13 @@ export default defineNuxtComponent({
|
||||
if (route.query.version) {
|
||||
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
|
||||
}
|
||||
if (versionList.length === 0) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: "No version matches the filters",
|
||||
});
|
||||
}
|
||||
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
|
||||
} else {
|
||||
version = props.versions.find((x) => x.id === route.params.version);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<template>
|
||||
<ConfirmModal
|
||||
v-if="currentMember"
|
||||
ref="deleteVersionModal"
|
||||
title="Are you sure you want to delete this version?"
|
||||
description="This will remove this version forever (like really forever)."
|
||||
:has-to-type="false"
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteVersion()"
|
||||
/>
|
||||
<section class="experimental-styles-within overflow-visible">
|
||||
<div
|
||||
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
|
||||
@@ -41,7 +50,7 @@
|
||||
:href="getPrimaryFile(version).url"
|
||||
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
|
||||
aria-label="Download"
|
||||
@click="emits('onDownload')"
|
||||
@click="emit('onDownload')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</a>
|
||||
@@ -57,7 +66,7 @@
|
||||
hoverFilled: true,
|
||||
link: getPrimaryFile(version).url,
|
||||
action: () => {
|
||||
emits('onDownload');
|
||||
emit('onDownload');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -101,8 +110,11 @@
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => {},
|
||||
shown: currentMember && false,
|
||||
action: () => {
|
||||
selectedVersion = version.id;
|
||||
deleteVersionModal.show();
|
||||
},
|
||||
shown: currentMember,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
@@ -144,7 +156,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ButtonStyled, OverflowMenu, FileInput, ProjectPageVersions } from "@modrinth/ui";
|
||||
import {
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
FileInput,
|
||||
ProjectPageVersions,
|
||||
ConfirmModal,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
DownloadIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -185,7 +203,10 @@ const tags = useTags();
|
||||
const flags = useFeatureFlags();
|
||||
const auth = await useAuth();
|
||||
|
||||
const emits = defineEmits(["onDownload"]);
|
||||
const deleteVersionModal = ref();
|
||||
const selectedVersion = ref(null);
|
||||
|
||||
const emit = defineEmits(["onDownload", "deleteVersion"]);
|
||||
|
||||
const router = useNativeRouter();
|
||||
|
||||
@@ -212,4 +233,9 @@ async function handleFiles(files) {
|
||||
async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function deleteVersion() {
|
||||
emit("deleteVersion", selectedVersion.value);
|
||||
selectedVersion.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
220
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
220
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<NewModal ref="refundModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Refund charge</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Refund type
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> The type of refund to issue. </span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="refund-type"
|
||||
v-model="refundType"
|
||||
:options="refundTypes"
|
||||
name="Refund type"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="refundType === 'partial'" class="flex flex-col gap-2">
|
||||
<label for="amount" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Amount
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>
|
||||
Enter the amount in cents of USD. For example for $2, enter 200. (net
|
||||
{{ selectedCharge.net }})
|
||||
</span>
|
||||
</label>
|
||||
<input id="amount" v-model="refundAmount" type="number" autocomplete="off" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="unprovision" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Unprovision
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
||||
</label>
|
||||
<Toggle
|
||||
id="unprovision"
|
||||
:model-value="unprovision"
|
||||
:checked="unprovision"
|
||||
@update:model-value="() => (unprovision = !unprovision)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="refunding" @click="refundCharge">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Refund charge
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="refundModal.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="normal-page no-sidebar">
|
||||
<h1>{{ user.username }}'s subscriptions</h1>
|
||||
<div class="normal-page__content">
|
||||
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
|
||||
<span class="font-extrabold text-contrast">
|
||||
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
||||
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||
Modrinth Servers
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
<template v-if="subscription.interval">
|
||||
{{ subscription.interval }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="mb-4 mt-2 flex items-center gap-1">
|
||||
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }}
|
||||
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template>
|
||||
</div>
|
||||
<div
|
||||
v-for="charge in subscription.charges"
|
||||
:key="charge.id"
|
||||
class="universal-card recessed flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge
|
||||
:color="charge.status === 'succeeded' ? 'green' : 'red'"
|
||||
:type="charge.status"
|
||||
/>
|
||||
⋅
|
||||
{{ charge.type }}
|
||||
⋅
|
||||
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
|
||||
⋅
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template>
|
||||
</div>
|
||||
<button
|
||||
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||
class="btn"
|
||||
@click="showRefundModal(charge)"
|
||||
>
|
||||
Refund charge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { CheckIcon, XIcon } from "@modrinth/assets";
|
||||
import { products } from "~/generated/state.json";
|
||||
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
const { formatMessage } = vintl;
|
||||
|
||||
const messages = defineMessages({
|
||||
userNotFoundError: {
|
||||
id: "admin.billing.error.not-found",
|
||||
defaultMessage: "User not found",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: user } = await useAsyncData(`user/${route.params.id}`, () =>
|
||||
useBaseFetch(`user/${route.params.id}`),
|
||||
);
|
||||
|
||||
if (!user.value) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
});
|
||||
}
|
||||
|
||||
let subscriptions, charges, refreshCharges;
|
||||
try {
|
||||
[{ data: subscriptions }, { data: charges, refresh: refreshCharges }] = await Promise.all([
|
||||
useAsyncData(`billing/subscriptions?user_id=${route.params.id}`, () =>
|
||||
useBaseFetch(`billing/subscriptions?user_id=${user.value.id}`, {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
useAsyncData(`billing/payments?user_id=${route.params.id}`, () =>
|
||||
useBaseFetch(`billing/payments?user_id=${user.value.id}`, {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
});
|
||||
}
|
||||
|
||||
const subscriptionCharges = computed(() => {
|
||||
return subscriptions.value.map((subscription) => {
|
||||
return {
|
||||
...subscription,
|
||||
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id),
|
||||
product: products.find((product) =>
|
||||
product.prices.some((price) => price.id === subscription.price_id),
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const refunding = ref(false);
|
||||
const refundModal = ref();
|
||||
const selectedCharge = ref(null);
|
||||
const refundType = ref("full");
|
||||
const refundTypes = ref(["full", "partial"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(false);
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
refundType.value = "full";
|
||||
refundAmount.value = 0;
|
||||
unprovision.value = false;
|
||||
refundModal.value.show();
|
||||
}
|
||||
|
||||
async function refundCharge() {
|
||||
refunding.value = true;
|
||||
try {
|
||||
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: refundType.value,
|
||||
amount: refundAmount.value,
|
||||
unprovision: unprovision.value,
|
||||
}),
|
||||
internal: true,
|
||||
});
|
||||
await refreshCharges();
|
||||
refundModal.value.hide();
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "Error refunding",
|
||||
text: err.data?.description ?? err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
refunding.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalConfirm
|
||||
v-if="auth.user && auth.user.id === creator.id"
|
||||
<ConfirmModal
|
||||
v-if="canEdit"
|
||||
ref="deleteModal"
|
||||
:title="formatMessage(messages.deleteModalTitle)"
|
||||
:description="formatMessage(messages.deleteModalDescription)"
|
||||
@@ -387,12 +387,13 @@ import {
|
||||
Avatar,
|
||||
Button,
|
||||
commonMessages,
|
||||
ConfirmModal,
|
||||
} from "@modrinth/ui";
|
||||
|
||||
import { isAdmin } from "@modrinth/utils";
|
||||
import WorldIcon from "assets/images/utils/world.svg";
|
||||
import UpToDate from "assets/images/illustrations/up_to_date.svg";
|
||||
import { addNotification } from "~/composables/notifs.js";
|
||||
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
|
||||
import NavRow from "~/components/ui/NavRow.vue";
|
||||
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
||||
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
@@ -596,7 +597,7 @@ useSeoMeta({
|
||||
const canEdit = computed(
|
||||
() =>
|
||||
auth.value.user &&
|
||||
auth.value.user.id === collection.value.user &&
|
||||
(auth.value.user.id === collection.value.user || isAdmin(auth.value.user)) &&
|
||||
collection.value.id !== "following",
|
||||
);
|
||||
|
||||
@@ -685,7 +686,11 @@ async function deleteCollection() {
|
||||
method: "DELETE",
|
||||
apiVersion: 3,
|
||||
});
|
||||
await navigateTo("/dashboard/collections");
|
||||
if (auth.value.user.id === collection.value.user) {
|
||||
await navigateTo("/dashboard/collections");
|
||||
} else {
|
||||
await navigateTo(`/user/${collection.value.user}/collections`);
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: "main",
|
||||
|
||||
@@ -38,9 +38,13 @@
|
||||
<div class="withdraw-options-scroll">
|
||||
<div class="withdraw-options">
|
||||
<button
|
||||
v-for="method in payoutMethods.filter((x) =>
|
||||
x.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)"
|
||||
v-for="method in payoutMethods
|
||||
.filter((x) => x.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) =>
|
||||
a.type !== 'tremendous'
|
||||
? -1
|
||||
: a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||
)"
|
||||
:key="method.id"
|
||||
class="withdraw-option button-base"
|
||||
:class="{ selected: selectedMethodId === method.id }"
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<div class="users-section">
|
||||
<div class="section-header">
|
||||
<div class="section-label green">For Players</div>
|
||||
<h2 class="section-tagline">Discover over 10,000 creations</h2>
|
||||
<h2 class="section-tagline">Discover over 50,000 creations</h2>
|
||||
<p class="section-description">
|
||||
From magical biomes to cursed dungeons, you can be sure to find content to bring your
|
||||
gameplay to the next level.
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
We aim to be as transparent as possible with creator revenue. All of our code is open source,
|
||||
including our
|
||||
<a href="https://github.com/modrinth/code/blob/main/apps/labrinth/src/queue/payouts.rs#L598">
|
||||
revenue distribution system </a
|
||||
revenue distribution system</a
|
||||
>. We also have an
|
||||
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
|
||||
to query exact daily revenue for the site.
|
||||
|
||||
@@ -1,99 +1,256 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Card>
|
||||
<div class="content">
|
||||
<div>
|
||||
<h1 class="card-title-adjustments">Submit a Report</h1>
|
||||
<div>
|
||||
<p>
|
||||
Modding should be safe for everyone, so we take abuse and malicious intent seriously
|
||||
at Modrinth. If you encounter content that violates our
|
||||
<nuxt-link class="text-link" to="/legal/terms">Terms of Service</nuxt-link> or our
|
||||
<nuxt-link class="text-link" to="/legal/rules">Rules</nuxt-link>, please report it to
|
||||
us here.
|
||||
</p>
|
||||
<p>
|
||||
This form is intended exclusively for reporting abuse or harmful content to Modrinth
|
||||
staff. For bugs related to specific projects, please use the project's designated
|
||||
Issues link or Discord channel.
|
||||
</p>
|
||||
<p>
|
||||
Your privacy is important to us; rest assured that your identifying information will
|
||||
be kept confidential.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-info-section">
|
||||
<div class="report-info-item">
|
||||
<label for="report-item">Item type to report</label>
|
||||
<DropdownSelect
|
||||
id="report-item"
|
||||
v-model="reportItem"
|
||||
name="report-item"
|
||||
:options="reportItems"
|
||||
:display-name="capitalizeString"
|
||||
:multiple="false"
|
||||
:searchable="false"
|
||||
:show-no-results="false"
|
||||
:show-labels="false"
|
||||
placeholder="Choose report item"
|
||||
/>
|
||||
</div>
|
||||
<div class="report-info-item">
|
||||
<label for="report-item-id">Item ID</label>
|
||||
<input
|
||||
id="report-item-id"
|
||||
v-model="reportItemID"
|
||||
type="text"
|
||||
placeholder="ex. project ID"
|
||||
autocomplete="off"
|
||||
:disabled="reportItem === ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="report-info-item">
|
||||
<label for="report-type">Reason for report</label>
|
||||
<DropdownSelect
|
||||
id="report-type"
|
||||
v-model="reportType"
|
||||
name="report-type"
|
||||
:options="reportTypes"
|
||||
:multiple="false"
|
||||
:searchable="false"
|
||||
:show-no-results="false"
|
||||
:show-labels="false"
|
||||
:display-name="capitalizeString"
|
||||
placeholder="Choose report type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-submission-section">
|
||||
<div>
|
||||
<p>
|
||||
Please provide additional context about your report. Include links and images if
|
||||
possible. <strong>Empty reports will be closed.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<MarkdownEditor v-model="reportBody" placeholder="" :on-image-upload="onImageUpload" />
|
||||
</div>
|
||||
<div class="submit-button">
|
||||
<Button
|
||||
id="submit-button"
|
||||
color="primary"
|
||||
:disabled="submitLoading || !canSubmit"
|
||||
@click="submitReport"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
Submit
|
||||
</Button>
|
||||
<div class="experimental-styles-within flex flex-col gap-2">
|
||||
<RadialHeader class="top-box mb-2 text-center" color="orange">
|
||||
<ScaleIcon class="h-12 w-12 text-brand-orange" />
|
||||
<h1 class="m-3 gap-2 text-3xl font-extrabold">
|
||||
{{
|
||||
prefilled && itemName
|
||||
? existingReport
|
||||
? formatMessage(messages.alreadyReportedItem, { title: itemName })
|
||||
: formatMessage(messages.reportItem, { title: itemName })
|
||||
: formatMessage(messages.reportContent)
|
||||
}}
|
||||
</h1>
|
||||
</RadialHeader>
|
||||
<div
|
||||
v-if="prefilled && itemName && existingReport"
|
||||
class="mx-auto flex max-w-[35rem] flex-col items-center gap-4 text-center"
|
||||
>
|
||||
{{ formatMessage(messages.alreadyReportedDescription, { item: reportItem || "content" }) }}
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled v-if="itemLink">
|
||||
<nuxt-link :to="itemLink">
|
||||
<LeftArrowIcon />
|
||||
{{ formatMessage(messages.backToItem, { item: reportItem || "content" }) }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link :to="`/dashboard/report/${existingReport.id}`">
|
||||
{{ formatMessage(messages.goToReport) }} <RightArrowIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<template v-else>
|
||||
<div class="mb-3 grid grid-cols-1 gap-4 px-6 md:grid-cols-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.pleaseReport) }}</h2>
|
||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||
<CheckCircleIcon class="h-8 w-8 shrink-0 text-brand-green" />
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
<IntlFormatted :message-id="messages.violation">
|
||||
<template #rules-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="`/legal/rules`">
|
||||
<component :is="() => children" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<template #terms-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="`/legal/terms`">
|
||||
<component :is="() => children" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-secondary">
|
||||
{{ formatMessage(messages.violationDescription) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.formNotFor) }}</h2>
|
||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
||||
<span>{{ formatMessage(messages.bugReports) }}</span>
|
||||
</div>
|
||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
||||
<div class="flex flex-col">
|
||||
<span>{{ formatMessage(messages.dmcaTakedown) }}</span>
|
||||
<span class="text-sm font-medium text-secondary">
|
||||
<IntlFormatted :message-id="messages.dmcaTakedownDescription">
|
||||
<template #policy-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="`/legal/copyright`">
|
||||
<component :is="() => children" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-6">
|
||||
<template v-if="!prefilled || !currentItemValid">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
{{ formatMessage(messages.whatContentType) }}
|
||||
</span>
|
||||
<RadioButtons
|
||||
v-slot="{ item }"
|
||||
v-model="reportItem"
|
||||
:items="reportItems"
|
||||
@update:model-value="
|
||||
() => {
|
||||
prefilled = false;
|
||||
fetchItem();
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ capitalizeString(item) }}
|
||||
</RadioButtons>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2" :class="{ hidden: !reportItem }">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
{{ formatMessage(messages.whatContentId, { item: reportItem || "content" }) }}
|
||||
</span>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
id="report-item-id"
|
||||
v-model="reportItemID"
|
||||
type="text"
|
||||
placeholder="ex: Dc7EYhxG"
|
||||
autocomplete="off"
|
||||
:disabled="reportItem === ''"
|
||||
class="w-40"
|
||||
@blur="
|
||||
() => {
|
||||
prefilled = false;
|
||||
reportItemID = reportItemID.trim();
|
||||
fetchItem();
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div v-if="checkingId || checkedId" class="flex items-center gap-1">
|
||||
<template v-if="checkingId">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.checking, { item: reportItem }) }}...
|
||||
</template>
|
||||
<template v-else-if="checkedId && itemName">
|
||||
<AutoLink
|
||||
:to="itemLink"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 font-bold text-contrast hover:underline"
|
||||
>
|
||||
<Avatar
|
||||
v-if="typeof itemIcon === 'string'"
|
||||
:src="itemIcon"
|
||||
:alt="itemName"
|
||||
size="24px"
|
||||
:circle="reportItem === 'user'"
|
||||
/>
|
||||
<component :is="itemIcon" v-else-if="itemIcon" />
|
||||
<span>{{ itemName }}</span>
|
||||
</AutoLink>
|
||||
<CheckIcon class="text-brand-green" />
|
||||
</template>
|
||||
<span v-else-if="checkedId" class="contents text-brand-red">
|
||||
<IssuesIcon />
|
||||
{{ formatMessage(messages.couldNotFind, { item: reportItem }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="existingReport">
|
||||
{{
|
||||
formatMessage(messages.alreadyReportedDescription, { item: reportItem || "content" })
|
||||
}}
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link :to="`/dashboard/report/${existingReport.id}`" class="w-fit">
|
||||
{{ formatMessage(messages.goToReport) }} <RightArrowIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2" :class="{ hidden: !reportItemID }">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
{{ formatMessage(messages.whatReportReason, { item: reportItem || "content" }) }}
|
||||
</span>
|
||||
<RadioButtons v-slot="{ item }" v-model="reportType" :items="reportTypes">
|
||||
{{ item === "copyright" ? "Reuploaded work" : capitalizeString(item) }}
|
||||
</RadioButtons>
|
||||
</div>
|
||||
<div
|
||||
v-if="warnings[reportType]"
|
||||
class="flex gap-2 rounded-xl border-2 border-solid border-brand-orange bg-highlight-orange p-4 text-contrast"
|
||||
>
|
||||
<IssuesIcon class="h-5 w-5 shrink-0 text-orange" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<p
|
||||
v-for="(warning, index) in warnings[reportType]"
|
||||
:key="`warning-${reportType}-${index}`"
|
||||
class="m-0 leading-tight"
|
||||
>
|
||||
<IntlFormatted :message-id="warning">
|
||||
<template #copyright-policy-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="`/legal/copyright`">
|
||||
<component :is="() => children" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="{ hidden: !reportType }">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
{{ formatMessage(messages.reportBodyTitle) }}
|
||||
</span>
|
||||
<p class="m-0 leading-tight text-secondary">
|
||||
{{ formatMessage(messages.reportBodyDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="{ hidden: !reportType }">
|
||||
<MarkdownEditor
|
||||
v-model="reportBody"
|
||||
placeholder=""
|
||||
:on-image-upload="onImageUpload"
|
||||
/>
|
||||
</div>
|
||||
<div :class="{ hidden: !reportType }">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
id="submit-button"
|
||||
:disabled="submitLoading || !canSubmit"
|
||||
@click="submitReport"
|
||||
>
|
||||
<SendIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.submitReport) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, Button, MarkdownEditor, DropdownSelect } from "@modrinth/ui";
|
||||
import { SaveIcon } from "@modrinth/assets";
|
||||
import {
|
||||
MarkdownEditor,
|
||||
RadialHeader,
|
||||
RadioButtons,
|
||||
ButtonStyled,
|
||||
Avatar,
|
||||
AutoLink,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
CheckIcon,
|
||||
SpinnerIcon,
|
||||
SendIcon,
|
||||
IssuesIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ScaleIcon,
|
||||
VersionIcon,
|
||||
} from "@modrinth/assets";
|
||||
import type { User, Version, Report } from "@modrinth/utils";
|
||||
import { useVIntl, defineMessages, type MessageDescriptor } from "@vintl/vintl";
|
||||
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||
|
||||
const tags = useTags();
|
||||
@@ -101,6 +258,7 @@ const route = useNativeRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const auth = await useAuth();
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
if (!auth.value.user) {
|
||||
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
|
||||
@@ -119,6 +277,80 @@ const reportItem = ref<string>(accessQuery("item"));
|
||||
const reportItemID = ref<string>(accessQuery("itemID"));
|
||||
const reportType = ref<string>("");
|
||||
|
||||
const prefilled = ref<boolean>(!!reportItem.value && !!reportItemID.value);
|
||||
const checkedId = ref<boolean>(false);
|
||||
const checkingId = ref<boolean>(false);
|
||||
|
||||
const currentProject = ref<Project | null>(null);
|
||||
const currentVersion = ref<Version | null>(null);
|
||||
const currentUser = ref<User | null>(null);
|
||||
|
||||
const itemIcon = ref<string | Component | undefined>();
|
||||
const itemName = ref<string | undefined>();
|
||||
const itemLink = ref<string | undefined>();
|
||||
const itemId = ref<string | undefined>();
|
||||
|
||||
const reports = ref<Report[]>([]);
|
||||
const existingReport = computed(() =>
|
||||
reports.value.find(
|
||||
(x) =>
|
||||
(x.item_id === reportItemID.value || x.item_id === itemId.value) &&
|
||||
x.item_type === reportItem.value,
|
||||
),
|
||||
);
|
||||
|
||||
await fetchItem();
|
||||
await fetchExistingReports();
|
||||
|
||||
const currentItemValid = computed(
|
||||
() => !!currentProject.value || !!currentVersion.value || !!currentUser.value,
|
||||
);
|
||||
|
||||
async function fetchExistingReports() {
|
||||
reports.value = ((await useBaseFetch("report?count=1000")) as Report[]).filter(
|
||||
(x) => x.reporter === auth.value.user?.id,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchItem() {
|
||||
if (reportItem.value && reportItemID.value) {
|
||||
checkingId.value = true;
|
||||
itemIcon.value = undefined;
|
||||
itemName.value = undefined;
|
||||
itemLink.value = undefined;
|
||||
itemId.value = undefined;
|
||||
try {
|
||||
if (reportItem.value === "project") {
|
||||
const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project;
|
||||
currentProject.value = project;
|
||||
|
||||
itemIcon.value = project.icon_url;
|
||||
itemName.value = project.title;
|
||||
itemLink.value = `/project/${project.id}`;
|
||||
itemId.value = project.id;
|
||||
} else if (reportItem.value === "version") {
|
||||
const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version;
|
||||
currentVersion.value = version;
|
||||
|
||||
itemIcon.value = VersionIcon;
|
||||
itemName.value = version.version_number;
|
||||
itemLink.value = `project/${version.project_id}/version/${version.id}`;
|
||||
itemId.value = version.id;
|
||||
} else if (reportItem.value === "user") {
|
||||
const user = (await useBaseFetch(`user/${reportItemID.value}`)) as User;
|
||||
currentUser.value = user;
|
||||
|
||||
itemIcon.value = user.avatar_url;
|
||||
itemName.value = user.username;
|
||||
itemLink.value = `/user/${user.username}`;
|
||||
itemId.value = user.id;
|
||||
}
|
||||
} catch {}
|
||||
checkedId.value = true;
|
||||
checkingId.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const reportItems = ["project", "version", "user"];
|
||||
const reportTypes = computed(() => tags.value.reportTypes);
|
||||
|
||||
@@ -232,70 +464,131 @@ const onImageUpload = async (file: File) => {
|
||||
uploadedImageIDs.value.push(item.id);
|
||||
return item.url;
|
||||
};
|
||||
|
||||
const warnings: Record<string, MessageDescriptor[]> = {
|
||||
copyright: [
|
||||
defineMessage({
|
||||
id: "report.note.copyright.1",
|
||||
defaultMessage:
|
||||
"Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content.",
|
||||
}),
|
||||
defineMessage({
|
||||
id: "report.note.copyright.2",
|
||||
defaultMessage:
|
||||
"If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>.",
|
||||
}),
|
||||
],
|
||||
malicious: [
|
||||
defineMessage({
|
||||
id: "report.note.malicious.1",
|
||||
defaultMessage:
|
||||
"Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples.",
|
||||
}),
|
||||
defineMessage({
|
||||
id: "report.note.malicious.2",
|
||||
defaultMessage:
|
||||
"Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted.",
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
reportContent: {
|
||||
id: "report.report-content",
|
||||
defaultMessage: "Report content to moderators",
|
||||
},
|
||||
reportItem: {
|
||||
id: "report.report-item",
|
||||
defaultMessage: "Report {title} to moderators",
|
||||
},
|
||||
alreadyReportedItem: {
|
||||
id: "report.already-reported",
|
||||
defaultMessage: "You've already reported {title}",
|
||||
},
|
||||
alreadyReportedDescription: {
|
||||
id: "report.already-reported-description",
|
||||
defaultMessage:
|
||||
"You have an open report for this {item} already. You can add more details to your report if you have more information to add.",
|
||||
},
|
||||
backToItem: {
|
||||
id: "report.back-to-item",
|
||||
defaultMessage: "Back to {item}",
|
||||
},
|
||||
goToReport: {
|
||||
id: "report.go-to-report",
|
||||
defaultMessage: "Go to report",
|
||||
},
|
||||
pleaseReport: {
|
||||
id: "report.please-report",
|
||||
defaultMessage: "Please report:",
|
||||
},
|
||||
formNotFor: {
|
||||
id: "report.form-not-for",
|
||||
defaultMessage: "This form is not for:",
|
||||
},
|
||||
violation: {
|
||||
id: "report.for.violation",
|
||||
defaultMessage:
|
||||
"Violation of Modrinth <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>",
|
||||
},
|
||||
violationDescription: {
|
||||
id: "report.for.violation.description",
|
||||
defaultMessage:
|
||||
"Examples include malicious, spam, offensive, deceptive, misleading, and illegal content.",
|
||||
},
|
||||
bugReports: {
|
||||
id: "report.not-for.bug-reports",
|
||||
defaultMessage: "Bug reports",
|
||||
},
|
||||
dmcaTakedown: {
|
||||
id: "report.not-for.dmca",
|
||||
defaultMessage: "DMCA takedowns",
|
||||
},
|
||||
dmcaTakedownDescription: {
|
||||
id: "report.not-for.dmca.description",
|
||||
defaultMessage: "See our <policy-link>Copyright Policy</policy-link>.",
|
||||
},
|
||||
whatContentType: {
|
||||
id: "report.question.content-type",
|
||||
defaultMessage: "What type of content are you reporting?",
|
||||
},
|
||||
whatContentId: {
|
||||
id: "report.question.content-id",
|
||||
defaultMessage: "What is the ID of the {item}?",
|
||||
},
|
||||
whatReportReason: {
|
||||
id: "report.question.report-reason",
|
||||
defaultMessage: "Which of Modrinth's rules is this {item} violating?",
|
||||
},
|
||||
checking: {
|
||||
id: "report.checking",
|
||||
defaultMessage: "Checking {item}...",
|
||||
},
|
||||
couldNotFind: {
|
||||
id: "report.could-not-find",
|
||||
defaultMessage: "Could not find {item}",
|
||||
},
|
||||
reportBodyTitle: {
|
||||
id: "report.body.title",
|
||||
defaultMessage: "Please provide additional context about your report",
|
||||
},
|
||||
reportBodyDescription: {
|
||||
id: "report.body.description",
|
||||
defaultMessage:
|
||||
"Include links and images if possible and relevant. Empty or insufficient reports will be closed and ignored.",
|
||||
},
|
||||
submitReport: {
|
||||
id: "report.submit",
|
||||
defaultMessage: "Submit report",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.submit-button {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
|
||||
margin-top: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.card-title-adjustments {
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 0.5rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
// TODO: Get rid of this hack when removing global styles from the website.
|
||||
// Overflow decides the behavior of md editor but also clips the border.
|
||||
// In the future, we should use ring instead of block-shadow for the
|
||||
// green ring around the md editor
|
||||
padding-inline: var(--gap-md);
|
||||
padding-bottom: var(--gap-md);
|
||||
margin-inline: calc(var(--gap-md) * -1);
|
||||
|
||||
display: grid;
|
||||
|
||||
// Disable horizontal stretch
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-info-section {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
gap: var(--gap-md);
|
||||
|
||||
:global(.animated-dropdown) {
|
||||
& > .selected {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.report-info-item {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--gap-sm);
|
||||
color: var(--color-text-dark);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
name="Sort by"
|
||||
:options="sortTypes"
|
||||
:display-name="(option) => option?.display"
|
||||
@change="updateSearchResults(1)"
|
||||
@change="updateSearchResults()"
|
||||
>
|
||||
<span class="font-semibold text-primary">Sort by: </span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
@@ -181,7 +181,7 @@
|
||||
:default-value="maxResults"
|
||||
:model-value="maxResults"
|
||||
class="!w-auto flex-grow md:flex-grow-0"
|
||||
@change="updateSearchResults(1)"
|
||||
@change="updateSearchResults()"
|
||||
>
|
||||
<span class="font-semibold text-primary">View: </span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
@@ -206,7 +206,7 @@
|
||||
:page="currentPage"
|
||||
:count="pageCount"
|
||||
class="mx-auto sm:ml-auto sm:mr-0"
|
||||
@switch-page="setPage"
|
||||
@switch-page="updateSearchResults"
|
||||
/>
|
||||
</div>
|
||||
<SearchFilterControl
|
||||
@@ -296,7 +296,7 @@
|
||||
:page="currentPage"
|
||||
:count="pageCount"
|
||||
class="justify-end"
|
||||
@switch-page="setPage"
|
||||
@switch-page="updateSearchResults"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,19 +545,13 @@ const pageCount = computed(() =>
|
||||
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
|
||||
);
|
||||
|
||||
function setPage(newPageNumber) {
|
||||
currentPage.value = newPageNumber;
|
||||
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
updateSearchResults();
|
||||
}
|
||||
|
||||
function scrollToTop(behavior = "smooth") {
|
||||
window.scrollTo({ top: 0, behavior });
|
||||
}
|
||||
|
||||
function updateSearchResults() {
|
||||
function updateSearchResults(pageNumber) {
|
||||
currentPage.value = pageNumber || 1;
|
||||
scrollToTop();
|
||||
noLoad.value = true;
|
||||
|
||||
if (query.value === null) {
|
||||
@@ -590,8 +584,8 @@ function updateSearchResults() {
|
||||
}
|
||||
}
|
||||
|
||||
watch([currentFilters, requestParams], () => {
|
||||
updateSearchResults();
|
||||
watch([currentFilters], () => {
|
||||
updateSearchResults(1);
|
||||
});
|
||||
|
||||
function cycleSearchDisplayMode() {
|
||||
|
||||
@@ -456,9 +456,9 @@
|
||||
Where are Modrinth Servers located? Can I choose a region?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Currently, Modrinth Servers are located in New York, Los Angeles, and Miami. More
|
||||
regions are coming soon! Your server's location is currently chosen algorithmically,
|
||||
but you will be able to choose a region in the future.
|
||||
Currently, Modrinth Servers are located in New York, Los Angeles, Seattle, and
|
||||
Miami. More regions are coming soon! Your server's location is currently chosen
|
||||
algorithmically, but you will be able to choose a region in the future.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -512,9 +512,9 @@
|
||||
: "There's a plan for everyone! Choose the one that fits your needs."
|
||||
}}
|
||||
<span class="font-bold">
|
||||
Servers are currently US only, in New York, Los Angeles, and Miami. More regions coming
|
||||
soon!</span
|
||||
>
|
||||
Servers are currently US only, in New York, Los Angeles, Seattle, and Miami. More
|
||||
regions coming soon!
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<ul class="m-0 flex w-full flex-col gap-8 p-0 lg:flex-row">
|
||||
@@ -533,9 +533,9 @@
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">4 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">32 GB Storage</p>
|
||||
<p class="m-0">4 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">2 vCPUs</p>
|
||||
<p class="m-0">32 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$12<span class="text-sm font-normal text-secondary">/month</span>
|
||||
@@ -585,9 +585,9 @@
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">6 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">48 GB Storage</p>
|
||||
<p class="m-0">6 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">3 vCPUs</p>
|
||||
<p class="m-0">48 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$18<span class="text-sm font-normal text-secondary">/month</span>
|
||||
@@ -626,9 +626,9 @@
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">8 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">64 GB Storage</p>
|
||||
<p class="m-0">8 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">4 vCPUs</p>
|
||||
<p class="m-0">64 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$24<span class="text-sm font-normal text-secondary">/month</span>
|
||||
@@ -656,11 +656,11 @@
|
||||
</ul>
|
||||
|
||||
<div
|
||||
class="flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left md:flex-row md:gap-0"
|
||||
class="flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="m-0">Build your own</h1>
|
||||
<h2 class="m-0 text-base font-normal">
|
||||
<h2 class="m-0 text-base font-normal text-primary">
|
||||
If you're a more technical server administrator, you can pick your own RAM and storage
|
||||
options.
|
||||
</h2>
|
||||
|
||||
@@ -19,7 +19,26 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="serverData?.status === 'suspended'"
|
||||
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'"
|
||||
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">
|
||||
@@ -69,6 +88,58 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error && server.error.message.includes('Service Unavailable')"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-red p-4">
|
||||
<PanelErrorIcon class="size-12 text-red" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||
</div>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your server's node, where your Modrinth Server is physically hosted, is experiencing
|
||||
issues. We are working with our datacenter to resolve the issue as quickly as possible.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your data is safe and will not be lost, and your server will be back online as soon as
|
||||
the issue is resolved.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
|
||||
bubble in the bottom right corner and we'll be happy to help.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<UiCopyCode :text="'Server ID: ' + server.serverId" />
|
||||
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="
|
||||
() =>
|
||||
navigateTo('https://discord.modrinth.com', {
|
||||
external: true,
|
||||
})
|
||||
"
|
||||
>
|
||||
<button class="mt-6 !w-full">Join Modrinth Discord</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
@@ -324,6 +395,7 @@ import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
||||
import { reloadNuxtApp } from "#app";
|
||||
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isReconnecting = ref(false);
|
||||
|
||||
@@ -1,65 +1,21 @@
|
||||
<template>
|
||||
<NewModal ref="modModal" header="Editing mod version">
|
||||
<div>
|
||||
<div class="mb-4 flex flex-col gap-4">
|
||||
<div class="inline-flex flex-wrap items-center">
|
||||
You're changing the version of
|
||||
<div class="inline-flex flex-wrap items-center gap-1 text-nowrap pl-2">
|
||||
<UiAvatar
|
||||
:src="currentMod?.icon_url"
|
||||
size="24px"
|
||||
class="inline-block"
|
||||
alt="Server Icon"
|
||||
/>
|
||||
<strong>{{ currentMod?.name + "." }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="props.server.general?.upstream" class="flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
Your server was created from a modpack. Changing the mod version may cause unexpected
|
||||
issues. You can update the modpack version in your server's Options > Platform
|
||||
settings.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-model="currentVersion"
|
||||
name="Project"
|
||||
:options="currentVersions"
|
||||
placeholder="Select project..."
|
||||
class="!w-full"
|
||||
:display-name="
|
||||
(version) => (typeof version === 'object' ? version?.version_number : version)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row items-center gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="currentMod.changing" @click="changeModVersion">
|
||||
<PlusIcon />
|
||||
Install
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modModal.value.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<UiServersContentVersionEditModal
|
||||
v-if="!invalidModal"
|
||||
ref="versionEditModal"
|
||||
:type="type"
|
||||
:mod-pack="Boolean(props.server.general?.upstream)"
|
||||
:game-version="props.server.general?.mc_version ?? ''"
|
||||
:loader="props.server.general?.loader?.toLowerCase() ?? ''"
|
||||
:server-id="props.server.serverId"
|
||||
@change-version="changeModVersion($event)"
|
||||
/>
|
||||
|
||||
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
||||
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
||||
<div class="relative flex h-full w-full flex-col">
|
||||
<div class="sticky top-0 z-20 -mt-4 flex items-center justify-between bg-bg py-4">
|
||||
<div class="flex w-full flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
||||
<div class="flex w-full items-center gap-2 sm:gap-4">
|
||||
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
|
||||
<div class="flex w-full flex-col-reverse items-center gap-2 sm:flex-row">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="relative flex-1 text-sm">
|
||||
<label class="sr-only" for="search">Search</label>
|
||||
<SearchIcon
|
||||
@@ -73,7 +29,7 @@
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:placeholder="`Search ${type.toLocaleLowerCase()}s...`"
|
||||
:placeholder="`Search ${localMods.length} ${type.toLocaleLowerCase()}s...`"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
@@ -88,7 +44,7 @@
|
||||
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
|
||||
]"
|
||||
>
|
||||
<span class="whitespace-pre text-sm font-medium">
|
||||
<span class="hidden whitespace-pre sm:block">
|
||||
{{ filterMethodLabel }}
|
||||
</span>
|
||||
<FilterIcon aria-hidden="true" />
|
||||
@@ -99,179 +55,255 @@
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ButtonStyled v-if="hasMods" color="brand" type="outlined">
|
||||
<nuxt-link
|
||||
class="w-full text-nowrap sm:w-fit"
|
||||
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add {{ type.toLocaleLowerCase() }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
|
||||
<ButtonStyled>
|
||||
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
|
||||
<FileIcon />
|
||||
Add file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link
|
||||
class="w-full text-nowrap sm:w-fit"
|
||||
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add {{ type.toLocaleLowerCase() }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasMods" class="flex flex-col gap-2 transition-all">
|
||||
<div ref="listContainer" class="relative w-full">
|
||||
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
|
||||
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
|
||||
<template v-for="mod in visibleItems.items" :key="mod.filename">
|
||||
<div
|
||||
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
|
||||
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
|
||||
style="height: 64px"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="
|
||||
mod.project_id
|
||||
? `/project/${mod.project_id}/version/${mod.version_id}`
|
||||
: `files?path=mods`
|
||||
"
|
||||
class="group flex min-w-0 items-center rounded-xl p-2"
|
||||
<FilesUploadDropdown
|
||||
v-if="props.server.fs"
|
||||
ref="uploadDropdownRef"
|
||||
class="rounded-xl bg-bg-raised"
|
||||
:margin-bottom="16"
|
||||
:file-type="type"
|
||||
:current-path="`/${type.toLocaleLowerCase()}s`"
|
||||
:fs="props.server.fs"
|
||||
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
||||
@upload-complete="() => props.server.refresh(['content'])"
|
||||
/>
|
||||
<FilesUploadDragAndDrop
|
||||
v-if="server.general && localMods"
|
||||
class="relative min-h-[50vh]"
|
||||
overlay-class="rounded-xl border-2 border-dashed border-secondary"
|
||||
:type="type"
|
||||
@files-dropped="handleDroppedFiles"
|
||||
>
|
||||
<div v-if="hasFilteredMods" class="flex flex-col gap-2 transition-all">
|
||||
<div ref="listContainer" class="relative w-full">
|
||||
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
|
||||
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
|
||||
<template v-for="mod in visibleItems.items" :key="mod.filename">
|
||||
<div
|
||||
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
|
||||
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
|
||||
style="height: 64px"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="
|
||||
mod.project_id
|
||||
? `/project/${mod.project_id}/version/${mod.version_id}`
|
||||
: `files?path=${type.toLocaleLowerCase()}s`
|
||||
"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
|
||||
draggable="false"
|
||||
>
|
||||
<UiAvatar
|
||||
:src="mod.icon_url"
|
||||
size="sm"
|
||||
alt="Server Icon"
|
||||
:class="mod.disabled ? 'grayscale' : ''"
|
||||
:class="mod.disabled ? 'opacity-75 grayscale' : ''"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="flex min-w-0 items-center gap-2 text-lg font-bold">
|
||||
<span class="truncate">{{
|
||||
mod.name || mod.filename.replace(".disabled", "")
|
||||
}}</span>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
|
||||
<span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
|
||||
<span
|
||||
v-if="mod.disabled"
|
||||
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
|
||||
>Disabled</span
|
||||
>
|
||||
</span>
|
||||
<span class="min-w-0 text-xs text-secondary">{{
|
||||
mod.version_number || "External mod"
|
||||
<div class="min-w-0 text-xs text-secondary">
|
||||
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
|
||||
<span class="block font-semibold sm:hidden">
|
||||
{{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
|
||||
<div class="truncate font-semibold text-contrast">
|
||||
<span v-tooltip="`${type} version`">{{
|
||||
mod.version_number || `External ${type.toLocaleLowerCase()}`
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<span v-tooltip="`${type} file name`">
|
||||
{{ mod.filename }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="flex items-center gap-2 pr-4 font-semibold text-contrast">
|
||||
<ButtonStyled v-if="mod.project_id" type="transparent">
|
||||
<button
|
||||
v-tooltip="'Edit mod version'"
|
||||
:disabled="mod.changing"
|
||||
class="!hidden sm:!block"
|
||||
@click="beginChangeModVersion(mod)"
|
||||
>
|
||||
<template v-if="mod.changing">
|
||||
<UiServersIconsLoadingIcon />
|
||||
</template>
|
||||
<template v-else>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="'Delete mod'"
|
||||
:disabled="mod.changing"
|
||||
class="!hidden sm:!block"
|
||||
@click="removeMod(mod)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<!-- Dropdown for mobile -->
|
||||
<div class="mr-2 flex items-center sm:hidden">
|
||||
<UiServersIconsLoadingIcon
|
||||
v-if="mod.changing"
|
||||
class="mr-2 h-5 w-5 animate-spin"
|
||||
style="color: var(--color-base)"
|
||||
/>
|
||||
<ButtonStyled v-else circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'edit',
|
||||
action: () => beginChangeModVersion(mod),
|
||||
shown: !!(mod.project_id && !mod.changing),
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
action: () => removeMod(mod),
|
||||
},
|
||||
]"
|
||||
<div
|
||||
class="flex items-center justify-end gap-2 pr-4 font-semibold text-contrast sm:min-w-44"
|
||||
>
|
||||
<ButtonStyled color="red" type="transparent">
|
||||
<button
|
||||
v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
|
||||
:disabled="mod.changing"
|
||||
class="!hidden sm:!block"
|
||||
@click="removeMod(mod)"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #edit>
|
||||
<EditIcon class="h-5 w-5" />
|
||||
<span>Edit</span>
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
<span>Delete</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
mod.project_id
|
||||
? `Edit ${type.toLocaleLowerCase()} version`
|
||||
: `External ${type.toLocaleLowerCase()}s cannot be edited`
|
||||
"
|
||||
:disabled="mod.changing || !mod.project_id"
|
||||
class="!hidden sm:!block"
|
||||
@click="showVersionModal(mod)"
|
||||
>
|
||||
<template v-if="mod.changing">
|
||||
<UiServersIconsLoadingIcon class="animate-spin" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<input
|
||||
:id="`toggle-${mod.filename}`"
|
||||
:checked="!mod.disabled"
|
||||
:disabled="mod.changing"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="toggleMod(mod)"
|
||||
/>
|
||||
<!-- Dropdown for mobile -->
|
||||
<div class="mr-2 flex items-center sm:hidden">
|
||||
<UiServersIconsLoadingIcon
|
||||
v-if="mod.changing"
|
||||
class="mr-2 h-5 w-5 animate-spin"
|
||||
style="color: var(--color-base)"
|
||||
/>
|
||||
<ButtonStyled v-else circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'edit',
|
||||
action: () => showVersionModal(mod),
|
||||
shown: !!(mod.project_id && !mod.changing),
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
action: () => removeMod(mod),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #edit>
|
||||
<EditIcon class="h-5 w-5" />
|
||||
<span>Edit</span>
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
<span>Delete</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<input
|
||||
:id="`toggle-${mod.filename}`"
|
||||
:checked="!mod.disabled"
|
||||
:disabled="mod.changing"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="toggleMod(mod)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- no mods has platform -->
|
||||
<div
|
||||
v-else-if="
|
||||
!hasMods &&
|
||||
props.server.general?.loader &&
|
||||
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
|
||||
"
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<PackageClosedIcon class="size-24" />
|
||||
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
||||
<p class="m-0">
|
||||
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
||||
</p>
|
||||
<ButtonStyled color="brand">
|
||||
<NuxtLink :to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`">
|
||||
<PlusIcon />
|
||||
Add {{ type.toLocaleLowerCase() }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
|
||||
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
|
||||
<p class="m-0">
|
||||
Add content to your server by installing a modpack or choosing a different platform that
|
||||
supports {{ type }}s.
|
||||
</p>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<ButtonStyled class="mt-8">
|
||||
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
|
||||
<CompassIcon />
|
||||
Find a modpack
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<div>or</div>
|
||||
<ButtonStyled class="mt-8">
|
||||
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
|
||||
<WrenchIcon />
|
||||
Change platform
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<!-- no mods has platform -->
|
||||
<div
|
||||
v-else-if="
|
||||
props.server.general?.loader &&
|
||||
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
|
||||
"
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<div
|
||||
v-if="!hasFilteredMods && hasMods"
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<SearchIcon class="size-24" />
|
||||
<p class="m-0 font-bold text-contrast">
|
||||
No {{ type.toLocaleLowerCase() }}s found for your query!
|
||||
</p>
|
||||
<p class="m-0">Try another query, or show everything.</p>
|
||||
<ButtonStyled>
|
||||
<button @click="showAll">
|
||||
<ListIcon />
|
||||
Show everything
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<PackageClosedIcon class="size-24" />
|
||||
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
||||
<p class="m-0">
|
||||
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
||||
</p>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
|
||||
<FileIcon />
|
||||
Add file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link
|
||||
class="w-full text-nowrap sm:w-fit"
|
||||
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add {{ type.toLocaleLowerCase() }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
|
||||
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
|
||||
<p class="m-0">
|
||||
Add content to your server by installing a modpack or choosing a different platform that
|
||||
supports {{ type }}s.
|
||||
</p>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<ButtonStyled class="mt-8">
|
||||
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
|
||||
<CompassIcon />
|
||||
Find a modpack
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<div>or</div>
|
||||
<ButtonStyled class="mt-8">
|
||||
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
|
||||
<WrenchIcon />
|
||||
Change platform
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</FilesUploadDragAndDrop>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -284,16 +316,19 @@ import {
|
||||
PackageClosedIcon,
|
||||
FilterIcon,
|
||||
DropdownIcon,
|
||||
InfoIcon,
|
||||
XIcon,
|
||||
PlusIcon,
|
||||
MoreVerticalIcon,
|
||||
CompassIcon,
|
||||
WrenchIcon,
|
||||
ListIcon,
|
||||
FileIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
||||
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
@@ -304,14 +339,7 @@ const type = computed(() => {
|
||||
return loader === "paper" || loader === "purpur" ? "Plugin" : "Mod";
|
||||
});
|
||||
|
||||
interface Mod {
|
||||
name?: string;
|
||||
filename: string;
|
||||
project_id?: string;
|
||||
version_id?: string;
|
||||
version_number?: string;
|
||||
icon_url?: string;
|
||||
disabled: boolean;
|
||||
interface ContentItem extends Mod {
|
||||
changing?: boolean;
|
||||
}
|
||||
|
||||
@@ -322,12 +350,99 @@ const listContainer = ref<HTMLElement | null>(null);
|
||||
const windowScrollY = ref(0);
|
||||
const windowHeight = ref(0);
|
||||
|
||||
const localMods = ref<Mod[]>([]);
|
||||
const localMods = ref<ContentItem[]>([]);
|
||||
|
||||
const searchInput = ref("");
|
||||
const modSearchInput = ref("");
|
||||
const filterMethod = ref("all");
|
||||
|
||||
const uploadDropdownRef = ref();
|
||||
|
||||
const versionEditModal = ref();
|
||||
const currentEditMod = ref<ContentItem | null>(null);
|
||||
const invalidModal = computed(
|
||||
() => !props.server.general?.mc_version || !props.server.general?.loader,
|
||||
);
|
||||
async function changeModVersion(event: string) {
|
||||
const mod = currentEditMod.value;
|
||||
|
||||
if (mod) mod.changing = true;
|
||||
|
||||
try {
|
||||
versionEditModal.value.hide();
|
||||
|
||||
// This will be used instead once backend implementation is done
|
||||
// await props.server.content?.reinstall(
|
||||
// `/${type.value.toLowerCase()}s/${event.fileName}`,
|
||||
// currentMod.value.project_id,
|
||||
// currentVersion.value.id,
|
||||
// );
|
||||
|
||||
await props.server.content?.install(
|
||||
type.value.toLowerCase() as "mod" | "plugin",
|
||||
mod?.project_id || "",
|
||||
event,
|
||||
);
|
||||
|
||||
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod?.filename}`);
|
||||
|
||||
await props.server.refresh(["general", "content"]);
|
||||
} catch (error) {
|
||||
const errmsg = `Error changing mod version: ${error}`;
|
||||
console.error(errmsg);
|
||||
addNotification({
|
||||
text: errmsg,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mod) mod.changing = false;
|
||||
}
|
||||
|
||||
function showVersionModal(mod: ContentItem) {
|
||||
if (invalidModal.value || !mod?.project_id || !mod?.filename) {
|
||||
const errmsg = invalidModal.value
|
||||
? "Data required for changing mod version was not found."
|
||||
: `${!mod?.project_id ? "No mod project ID found" : "No mod filename found"} for ${friendlyModName(mod!)}`;
|
||||
console.error(errmsg);
|
||||
addNotification({
|
||||
text: errmsg,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
currentEditMod.value = mod;
|
||||
versionEditModal.value.show(mod);
|
||||
}
|
||||
|
||||
const handleDroppedFiles = (files: File[]) => {
|
||||
files.forEach((file) => {
|
||||
uploadDropdownRef.value?.uploadFile(file);
|
||||
});
|
||||
};
|
||||
|
||||
const initiateFileUpload = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = acceptFileFromProjectType(type.value.toLowerCase());
|
||||
input.multiple = true;
|
||||
input.onchange = () => {
|
||||
if (input.files) {
|
||||
Array.from(input.files).forEach((file) => {
|
||||
uploadDropdownRef.value?.uploadFile(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const showAll = () => {
|
||||
searchInput.value = "";
|
||||
modSearchInput.value = "";
|
||||
filterMethod.value = "all";
|
||||
};
|
||||
|
||||
const filterMethodLabel = computed(() => {
|
||||
switch (filterMethod.value) {
|
||||
case "disabled":
|
||||
@@ -419,24 +534,40 @@ const debouncedSearch = debounce(() => {
|
||||
modSearchInput.value = searchInput.value;
|
||||
|
||||
if (pyroContentSentinel.value) {
|
||||
pyroContentSentinel.value.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
const sentinelRect = pyroContentSentinel.value.getBoundingClientRect();
|
||||
if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) {
|
||||
pyroContentSentinel.value.scrollIntoView({
|
||||
// behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
async function toggleMod(mod: Mod) {
|
||||
function friendlyModName(mod: ContentItem) {
|
||||
if (mod.name) return mod.name;
|
||||
|
||||
// remove .disabled if at the end of the filename
|
||||
let cleanName = mod.filename.endsWith(".disabled") ? mod.filename.slice(0, -9) : mod.filename;
|
||||
|
||||
// remove everything after the last dot
|
||||
const lastDotIndex = cleanName.lastIndexOf(".");
|
||||
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex);
|
||||
return cleanName;
|
||||
}
|
||||
|
||||
async function toggleMod(mod: ContentItem) {
|
||||
mod.changing = true;
|
||||
|
||||
const originalFilename = mod.filename;
|
||||
try {
|
||||
const newFilename = mod.filename.endsWith(".disabled")
|
||||
? mod.filename.replace(".disabled", "")
|
||||
? mod.filename.slice(0, -9)
|
||||
: `${mod.filename}.disabled`;
|
||||
|
||||
const sourcePath = `/mods/${mod.filename}`;
|
||||
const destinationPath = `/mods/${newFilename}`;
|
||||
const folder = `${type.value.toLocaleLowerCase()}s`;
|
||||
const sourcePath = `/${folder}/${mod.filename}`;
|
||||
const destinationPath = `/${folder}/${newFilename}`;
|
||||
|
||||
mod.disabled = newFilename.endsWith(".disabled");
|
||||
mod.filename = newFilename;
|
||||
@@ -450,7 +581,7 @@ async function toggleMod(mod: Mod) {
|
||||
|
||||
console.error("Error toggling mod:", error);
|
||||
addNotification({
|
||||
text: `Something went wrong toggling ${mod.name || mod.filename.replace(".disabled", "")}`,
|
||||
text: `Something went wrong toggling ${friendlyModName(mod)}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -458,14 +589,11 @@ async function toggleMod(mod: Mod) {
|
||||
mod.changing = false;
|
||||
}
|
||||
|
||||
async function removeMod(mod: Mod) {
|
||||
async function removeMod(mod: ContentItem) {
|
||||
mod.changing = true;
|
||||
|
||||
try {
|
||||
await props.server.content?.remove(
|
||||
type.value as "Mod" | "Plugin",
|
||||
`/${type.value.toLowerCase()}s/${mod.filename}`,
|
||||
);
|
||||
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`);
|
||||
await props.server.refresh(["general", "content"]);
|
||||
} catch (error) {
|
||||
console.error("Error removing mod:", error);
|
||||
@@ -479,42 +607,11 @@ async function removeMod(mod: Mod) {
|
||||
mod.changing = false;
|
||||
}
|
||||
|
||||
const modModal = ref();
|
||||
const currentMod = ref();
|
||||
const currentVersions = ref();
|
||||
const currentVersion = ref();
|
||||
|
||||
async function beginChangeModVersion(mod: Mod) {
|
||||
currentMod.value = mod;
|
||||
currentVersions.value = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
|
||||
|
||||
currentVersions.value = currentVersions.value.filter((version: any) =>
|
||||
version.loaders.includes(props.server.general?.loader?.toLowerCase()),
|
||||
);
|
||||
|
||||
currentVersion.value = currentVersions.value.find(
|
||||
(version: any) => version.id === mod.version_id,
|
||||
);
|
||||
modModal.value.show();
|
||||
}
|
||||
|
||||
async function changeModVersion() {
|
||||
currentMod.value.changing = true;
|
||||
try {
|
||||
modModal.value.hide();
|
||||
await props.server.content?.reinstall(
|
||||
type.value,
|
||||
currentMod.value.version_id,
|
||||
currentVersion.value.id,
|
||||
);
|
||||
await props.server.refresh(["general", "content"]);
|
||||
} catch (error) {
|
||||
console.error("Error changing mod version:", error);
|
||||
}
|
||||
currentMod.value.changing = false;
|
||||
}
|
||||
|
||||
const hasMods = computed(() => {
|
||||
return localMods.value?.length > 0;
|
||||
});
|
||||
|
||||
const hasFilteredMods = computed(() => {
|
||||
return filteredMods.value?.length > 0;
|
||||
});
|
||||
|
||||
@@ -539,9 +636,7 @@ const filteredMods = computed(() => {
|
||||
})();
|
||||
|
||||
return statusFilteredMods.sort((a, b) => {
|
||||
const aName = a.name || a.filename.replace(".disabled", "");
|
||||
const bName = b.name || b.filename.replace(".disabled", "");
|
||||
return aName.localeCompare(bName);
|
||||
return friendlyModName(a).localeCompare(friendlyModName(b));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -25,12 +25,9 @@
|
||||
@delete="handleDeleteItem"
|
||||
/>
|
||||
|
||||
<div
|
||||
<FilesUploadDragAndDrop
|
||||
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
@files-dropped="handleDroppedFiles"
|
||||
>
|
||||
<div ref="mainContent" class="relative isolate flex w-full flex-col">
|
||||
<div v-if="!isEditing" class="contents">
|
||||
@@ -44,94 +41,14 @@
|
||||
@upload="initiateFileUpload"
|
||||
@update:search-query="searchQuery = $event"
|
||||
/>
|
||||
<Transition
|
||||
name="upload-status"
|
||||
@enter="onUploadStatusEnter"
|
||||
@leave="onUploadStatusLeave"
|
||||
>
|
||||
<div
|
||||
v-if="isUploading"
|
||||
ref="uploadStatusRef"
|
||||
class="upload-status rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow text-contrast"
|
||||
>
|
||||
<div class="flex flex-col p-4 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
File Uploads{{
|
||||
activeUploads.length > 0 ? ` - ${activeUploads.length} left` : ""
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<UiServersPanelSpinner
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="item.status === 'error' || item.status === 'cancelled'"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<FilesUploadDropdown
|
||||
v-if="props.server.fs"
|
||||
ref="uploadDropdownRef"
|
||||
class="rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow"
|
||||
:current-path="currentPath"
|
||||
:fs="props.server.fs"
|
||||
@upload-complete="refreshList()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UiServersFilesEditingNavbar
|
||||
@@ -220,7 +137,7 @@
|
||||
<p class="mt-2 text-xl">Drop files here to upload</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FilesUploadDragAndDrop>
|
||||
|
||||
<UiServersFilesContextMenu
|
||||
ref="contextMenu"
|
||||
@@ -238,9 +155,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInfiniteScroll } from "@vueuse/core";
|
||||
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
|
||||
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
||||
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
|
||||
|
||||
interface BaseOperation {
|
||||
type: "move" | "rename";
|
||||
@@ -263,14 +181,6 @@ interface RenameOperation extends BaseOperation {
|
||||
|
||||
type Operation = MoveOperation | RenameOperation;
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: "pending" | "uploading" | "completed" | "error" | "cancelled";
|
||||
size: string;
|
||||
uploader?: any;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
@@ -312,46 +222,8 @@ const isEditingImage = ref(false);
|
||||
const imagePreview = ref();
|
||||
|
||||
const isDragging = ref(false);
|
||||
const dragCounter = ref(0);
|
||||
|
||||
const uploadStatusRef = ref<HTMLElement | null>(null);
|
||||
const isUploading = computed(() => uploadQueue.value.length > 0);
|
||||
const uploadQueue = ref<UploadItem[]>([]);
|
||||
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
||||
);
|
||||
|
||||
const onUploadStatusEnter = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight;
|
||||
(el as HTMLElement).style.height = "0";
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
};
|
||||
|
||||
const onUploadStatusLeave = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight;
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = "0";
|
||||
};
|
||||
|
||||
watch(
|
||||
uploadQueue,
|
||||
() => {
|
||||
if (!uploadStatusRef.value) return;
|
||||
const el = uploadStatusRef.value;
|
||||
const itemsHeight = uploadQueue.value.length * 32;
|
||||
const headerHeight = 12;
|
||||
const gap = 8;
|
||||
const padding = 32;
|
||||
const totalHeight = padding + headerHeight + gap + itemsHeight;
|
||||
el.style.height = `${totalHeight}px`;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
const uploadDropdownRef = ref();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
@@ -917,135 +789,12 @@ const requestShareLink = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
const handleDroppedFiles = (files: File[]) => {
|
||||
if (isEditing.value) return;
|
||||
event.preventDefault();
|
||||
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
||||
dragCounter.value++;
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
if (isEditing.value) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
if (isEditing.value) return;
|
||||
event.preventDefault();
|
||||
dragCounter.value--;
|
||||
if (dragCounter.value === 0) {
|
||||
isDragging.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (isEditing.value) return;
|
||||
event.preventDefault();
|
||||
isDragging.value = false;
|
||||
dragCounter.value = 0;
|
||||
|
||||
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
|
||||
if (isInternalMove) return;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files) {
|
||||
Array.from(files).forEach((file) => {
|
||||
uploadFile(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
};
|
||||
|
||||
const cancelUpload = (item: UploadItem) => {
|
||||
if (item.uploader && item.status === "uploading") {
|
||||
item.uploader.cancel();
|
||||
item.status = "cancelled";
|
||||
|
||||
setTimeout(async () => {
|
||||
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const uploadItem: UploadItem = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
size: formatFileSize(file.size),
|
||||
};
|
||||
|
||||
uploadQueue.value.push(uploadItem);
|
||||
|
||||
try {
|
||||
uploadItem.status = "uploading";
|
||||
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
|
||||
const uploader = await props.server.fs?.uploadFile(filePath, file);
|
||||
uploadItem.uploader = uploader;
|
||||
|
||||
if (uploader?.onProgress) {
|
||||
uploader.onProgress(({ progress }: { progress: number }) => {
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value[index].progress = Math.round(progress);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await uploader?.promise;
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status = "completed";
|
||||
uploadQueue.value[index].progress = 100;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
await refreshList();
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status = "error";
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
if (error instanceof Error && error.message !== "Upload cancelled") {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Upload failed",
|
||||
text: `Failed to upload ${file.name}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
files.forEach((file) => {
|
||||
uploadDropdownRef.value?.uploadFile(file);
|
||||
});
|
||||
};
|
||||
|
||||
const initiateFileUpload = () => {
|
||||
@@ -1055,7 +804,7 @@ const initiateFileUpload = () => {
|
||||
input.onchange = () => {
|
||||
if (input.files) {
|
||||
Array.from(input.files).forEach((file) => {
|
||||
uploadFile(file);
|
||||
uploadDropdownRef.value?.uploadFile(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -237,24 +237,11 @@ interface ErrorData {
|
||||
}
|
||||
|
||||
const inspectingError = ref<ErrorData | null>(null);
|
||||
const mcError = ref<any>(null);
|
||||
|
||||
const inspectError = async () => {
|
||||
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
||||
const response = (await $fetch("https://api.mclo.gs/1/log", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
content: log,
|
||||
}),
|
||||
})) as any;
|
||||
|
||||
mcError.value = response;
|
||||
|
||||
// @ts-ignore
|
||||
const analysis = (await $fetch(`https://api.mclo.gs/1/insights/${response.id}`, {
|
||||
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -269,7 +256,6 @@ const inspectError = async () => {
|
||||
|
||||
const clearError = () => {
|
||||
inspectingError.value = null;
|
||||
mcError.value = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -330,11 +330,11 @@
|
||||
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<DownloadIcon v-if="hasNewerVersion" color="brand">
|
||||
<ButtonStyled v-if="hasNewerVersion" color="brand">
|
||||
<button class="!w-full sm:!w-auto" @click="handleUpdateToLatest">
|
||||
<UploadIcon class="size-4" /> Update modpack
|
||||
</button>
|
||||
</DownloadIcon>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.upstream" class="contents">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</template>
|
||||
</span>
|
||||
⋅
|
||||
<span>{{ formatPrice(charge.amount, charge.currency_code) }}</span>
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
|
||||
@@ -39,6 +39,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Badge } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { products } from "~/generated/state.json";
|
||||
|
||||
definePageMeta({
|
||||
@@ -66,19 +67,4 @@ const { data: charges } = await useAsyncData(
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// TODO move to omorphia utils , duplicated from index
|
||||
function formatPrice(price, currency) {
|
||||
const formatter = new Intl.NumberFormat(vintl.locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
});
|
||||
|
||||
const maxDigits = formatter.resolvedOptions().maximumFractionDigits;
|
||||
|
||||
const convertedPrice = price / Math.pow(10, maxDigits);
|
||||
|
||||
return formatter.format(convertedPrice);
|
||||
}
|
||||
console.log(charges);
|
||||
</script>
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
v-else-if="getPyroCharge(subscription).status === 'processing'"
|
||||
class="text-sm text-orange"
|
||||
>
|
||||
Your payment is being processed. Perks will activate once payment is
|
||||
Your payment is being processed. Your server will activate once payment is
|
||||
complete.
|
||||
</span>
|
||||
<span
|
||||
@@ -270,7 +270,8 @@
|
||||
v-else-if="getPyroCharge(subscription).status === 'failed'"
|
||||
class="text-sm text-red"
|
||||
>
|
||||
Your subscription payment failed. Please update your payment method.
|
||||
Your subscription payment failed. Please update your payment method, then
|
||||
resubscribe.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,7 +279,8 @@
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
getPyroCharge(subscription) &&
|
||||
getPyroCharge(subscription).status !== 'cancelled'
|
||||
getPyroCharge(subscription).status !== 'cancelled' &&
|
||||
getPyroCharge(subscription).status !== 'failed'
|
||||
"
|
||||
type="standard"
|
||||
@click="showPyroCancelModal(subscription.id)"
|
||||
@@ -291,7 +293,8 @@
|
||||
<ButtonStyled
|
||||
v-else-if="
|
||||
getPyroCharge(subscription) &&
|
||||
getPyroCharge(subscription).status === 'cancelled'
|
||||
(getPyroCharge(subscription).status === 'cancelled' ||
|
||||
getPyroCharge(subscription).status === 'failed')
|
||||
"
|
||||
type="standard"
|
||||
color="green"
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
<div v-if="user" class="experimental-styles-within">
|
||||
<ModalCreation ref="modal_creation" />
|
||||
<CollectionCreateModal ref="modal_collection_creation" />
|
||||
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary">Email</span>
|
||||
<div>
|
||||
<span
|
||||
v-tooltip="user.email_verified ? 'Email verified' : 'Email not verified'"
|
||||
class="flex w-fit items-center gap-1"
|
||||
>
|
||||
<span>{{ user.email }}</span>
|
||||
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
|
||||
<XIcon v-else class="h-4 w-4 text-red" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Auth providers </span>
|
||||
<span>{{ user.auth_providers.join(", ") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Payment methods</span>
|
||||
<span>
|
||||
<template v-if="user.payout_data?.paypal_address">
|
||||
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
|
||||
</template>
|
||||
<template v-if="user.payout_data?.paypal_address && user.payout_data?.venmo_address">
|
||||
,
|
||||
</template>
|
||||
<template v-if="user.payout_data?.venmo_address">
|
||||
Venmo ({{ user.payout_data.venmo_address }})
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Has password </span>
|
||||
<span>
|
||||
{{ user.has_password ? "Yes" : "No" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Has TOTP </span>
|
||||
<span>
|
||||
{{ user.has_totp ? "Yes" : "No" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
|
||||
<div class="normal-page__header py-4">
|
||||
<ContentPageHeader>
|
||||
@@ -74,6 +125,16 @@
|
||||
shown: auth.user?.id !== user.id,
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
{
|
||||
id: 'open-billing',
|
||||
action: () => navigateTo(`/admin/billing/${user.id}`),
|
||||
shown: auth.user && isStaff(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'open-info',
|
||||
action: () => $refs.userDetailsModal.show(),
|
||||
shown: auth.user && isStaff(auth.user),
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
@@ -90,6 +151,14 @@
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
||||
</template>
|
||||
<template #open-billing>
|
||||
<CurrencyIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.billingButton) }}
|
||||
</template>
|
||||
<template #open-info>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.infoButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -264,8 +333,18 @@ import {
|
||||
DownloadIcon,
|
||||
ClipboardCopyIcon,
|
||||
MoreVerticalIcon,
|
||||
CurrencyIcon,
|
||||
InfoIcon,
|
||||
CheckIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { OverflowMenu, ButtonStyled, ContentPageHeader, commonMessages } from "@modrinth/ui";
|
||||
import {
|
||||
OverflowMenu,
|
||||
ButtonStyled,
|
||||
ContentPageHeader,
|
||||
commonMessages,
|
||||
NewModal,
|
||||
} from "@modrinth/ui";
|
||||
import { isStaff } from "~/helpers/users.js";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
||||
import { reportUser } from "~/utils/report-helpers.ts";
|
||||
@@ -367,6 +446,14 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
|
||||
},
|
||||
billingButton: {
|
||||
id: "profile.button.billing",
|
||||
defaultMessage: "Manage user billing",
|
||||
},
|
||||
infoButton: {
|
||||
id: "profile.button.info",
|
||||
defaultMessage: "View user details",
|
||||
},
|
||||
userNotFoundError: {
|
||||
id: "profile.error.not-found",
|
||||
defaultMessage: "User not found",
|
||||
|
||||
Reference in New Issue
Block a user