1
0

Admin & staff page enhancements (#3333)

This commit is contained in:
Prospector
2025-03-03 22:22:25 -08:00
committed by GitHub
parent c2d455f166
commit 36cfcc2093
8 changed files with 295 additions and 128 deletions

View File

@@ -227,7 +227,6 @@
<template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template> <template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template>
</TeleportOverflowMenu> </TeleportOverflowMenu>
</ButtonStyled> </ButtonStyled>
<ButtonStyled <ButtonStyled
type="transparent" type="transparent"
:highlighted=" :highlighted="
@@ -252,14 +251,52 @@
</ButtonStyled> </ButtonStyled>
</template> </template>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-1">
<ButtonStyled type="transparent">
<OverflowMenu
v-if="auth.user && isStaff(auth.user)"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
position="bottom"
direction="left"
:dropdown-id="`${basePopoutId}-staff`"
aria-label="Create new..."
:options="[
{
id: 'review-projects',
color: 'orange',
link: '/moderation/review',
},
{
id: 'review-reports',
color: 'orange',
link: '/moderation/reports',
},
{
divider: true,
shown: isAdmin(auth.user),
},
{
id: 'user-lookup',
color: 'primary',
link: '/admin/user_email',
shown: isAdmin(auth.user),
},
]"
>
<ModrinthIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #review-projects> <ScaleIcon aria-hidden="true" /> Review projects </template>
<template #review-reports> <ReportIcon aria-hidden="true" /> Reports </template>
<template #user-lookup> <UserIcon aria-hidden="true" /> Lookup by email </template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled type="transparent"> <ButtonStyled type="transparent">
<OverflowMenu <OverflowMenu
v-if="auth.user" v-if="auth.user"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1" class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
position="bottom" position="bottom"
direction="left" direction="left"
:dropdown-id="createPopoutId" :dropdown-id="`${basePopoutId}-create`"
aria-label="Create new..." aria-label="Create new..."
:options="[ :options="[
{ {
@@ -291,7 +328,7 @@
</ButtonStyled> </ButtonStyled>
<OverflowMenu <OverflowMenu
v-if="auth.user" v-if="auth.user"
:dropdown-id="userPopoutId" :dropdown-id="`${basePopoutId}-user`"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1" class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
:options="userMenuOptions" :options="userMenuOptions"
> >
@@ -312,7 +349,7 @@
</template> </template>
<template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </template> <template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </template>
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template> <template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
<template #moderation> <ModerationIcon aria-hidden="true" /> Moderation </template> <template #moderation> <ScaleIcon aria-hidden="true" /> Moderation </template>
<template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template> <template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template>
</OverflowMenu> </OverflowMenu>
<template v-else> <template v-else>
@@ -399,7 +436,7 @@
class="iconified-button" class="iconified-button"
to="/moderation" to="/moderation"
> >
<ModerationIcon aria-hidden="true" /> <ScaleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.moderationLabel) }} {{ formatMessage(commonMessages.moderationLabel) }}
</NuxtLink> </NuxtLink>
<NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags"> <NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags">
@@ -460,7 +497,7 @@
} }
" "
> >
<NotificationIcon aria-hidden="true" /> <BellIcon aria-hidden="true" />
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/dashboard" to="/dashboard"
@@ -479,7 +516,7 @@
> >
<template v-if="!auth.user"> <template v-if="!auth.user">
<HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" /> <HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" />
<CrossIcon v-else aria-hidden="true" /> <XIcon v-else aria-hidden="true" />
</template> </template>
<template v-else> <template v-else>
<Avatar <Avatar
@@ -589,6 +626,7 @@
</template> </template>
<script setup> <script setup>
import { import {
ModrinthIcon,
ArrowBigUpDashIcon, ArrowBigUpDashIcon,
BookmarkIcon, BookmarkIcon,
ServerIcon, ServerIcon,
@@ -626,17 +664,17 @@ import {
TwitterIcon, TwitterIcon,
MastodonIcon, MastodonIcon,
GitHubIcon, GitHubIcon,
XIcon as CrossIcon, ScaleIcon,
ScaleIcon as ModerationIcon,
BellIcon as NotificationIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui"; import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
import { isAdmin, isStaff } from "@modrinth/utils";
import ModalCreation from "~/components/ui/ModalCreation.vue"; import ModalCreation from "~/components/ui/ModalCreation.vue";
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts"; import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue"; import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import OrganizationCreateModal from "~/components/ui/OrganizationCreateModal.vue"; import OrganizationCreateModal from "~/components/ui/OrganizationCreateModal.vue";
import TeleportOverflowMenu from "~/components/ui/servers/TeleportOverflowMenu.vue"; import TeleportOverflowMenu from "~/components/ui/servers/TeleportOverflowMenu.vue";
import Report from "~/pages/report.vue";
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl();
@@ -652,8 +690,7 @@ const route = useNativeRoute();
const router = useNativeRouter(); const router = useNativeRouter();
const link = config.public.siteUrl + route.path.replace(/\/+$/, ""); const link = config.public.siteUrl + route.path.replace(/\/+$/, "");
const createPopoutId = useId(); const basePopoutId = useId();
const userPopoutId = useId();
const verifyEmailBannerMessages = defineMessages({ const verifyEmailBannerMessages = defineMessages({
title: { title: {

View File

@@ -58,50 +58,137 @@
</div> </div>
</div> </div>
</NewModal> </NewModal>
<div class="normal-page no-sidebar"> <div class="page experimental-styles-within">
<h1>{{ user.username }}'s subscriptions</h1> <div
<div class="normal-page__content"> class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
>
<div class="flex items-center gap-2">
<Avatar :src="user.avatar_url" :alt="user.username" size="32px" circle />
<h1 class="m-0 text-2xl font-extrabold">{{ user.username }}'s subscriptions</h1>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<nuxt-link :to="`/user/${user.id}`">
<UserIcon aria-hidden="true" />
User profile
<ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div>
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card"> <div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
<span class="font-extrabold text-contrast"> <div class="mb-4 grid grid-cols-[1fr_auto]">
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template> <div>
<template v-else-if="subscription.product.metadata.type === 'pyro'"> <span class="flex items-center gap-2 font-semibold text-contrast">
Modrinth Servers <template v-if="subscription.product.metadata.type === 'midas'">
</template> <ModrinthPlusIcon class="h-7 w-min" />
<template v-else> Unknown product </template> </template>
<template v-if="subscription.interval"> <template v-else-if="subscription.product.metadata.type === 'pyro'">
{{ subscription.interval }} <ModrinthServersIcon class="h-7 w-min" />
</template> </template>
</span> <template v-else> Unknown product </template>
<div class="mb-4 mt-2 flex items-center gap-1"> </span>
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }} <div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template> {{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
</div> {{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
<div dayjs(subscription.created).fromNow()
v-for="charge in subscription.charges" }})
:key="charge.id" </div>
class="universal-card recessed flex items-center justify-between gap-4" </div>
> <div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<div class="flex w-full items-center justify-between gap-4"> <ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
<div class="flex items-center gap-1"> <nuxt-link
<Badge :to="`/servers/manage/${subscription.metadata.id}`"
:color="charge.status === 'succeeded' ? 'green' : 'red'" target="_blank"
:type="charge.status" class="w-fit"
/> >
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
{{ charge.type }} </nuxt-link>
</ButtonStyled>
{{ $dayjs(charge.due).format("YYYY-MM-DD") }} <CopyCode :text="subscription.metadata.id" />
</div>
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span> </div>
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template> <div class="flex flex-col gap-2">
<div
v-for="(charge, index) in subscription.charges"
:key="charge.id"
class="relative overflow-clip rounded-xl bg-bg px-4 py-3"
>
<div
class="absolute bottom-0 left-0 top-0 w-1"
:class="charge.type === 'refund' ? 'bg-purple' : chargeStatuses[charge.status].color"
/>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
<div class="flex flex-col gap-2">
<span>
<span class="font-bold text-contrast">
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
<template v-else-if="charge.status === 'failed'"> Failed </template>
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
<template v-else-if="charge.status === 'processing'"> Processing </template>
<template v-else-if="charge.status === 'open'"> Upcoming </template>
<template v-else> {{ charge.status }} </template>
</span>
<span>
<template v-if="charge.type === 'refund'"> Refund </template>
<template v-else-if="charge.type === 'subscription'">
<template v-if="charge.status === 'cancelled'"> Subscription </template>
<template v-else-if="index === subscription.charges.length - 1">
Started subscription
</template>
<template v-else> Subscription renewal </template>
</template>
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
<template v-else> {{ charge.status }} </template>
</span>
<template v-if="charge.status !== 'cancelled'">
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
</template>
</span>
<span class="text-sm text-secondary">
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ dayjs(charge.due).fromNow() }}) </span>
</span>
<div
v-if="flags.developerMode"
class="flex w-full items-center gap-1 text-xs text-secondary"
>
{{ charge.status }}
{{ charge.type }}
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
{{ dayjs(charge.due).format("YYYY-MM-DD h:mma") }}
<template v-if="charge.subscription_interval">
⋅ {{ charge.subscription_interval }}
</template>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled
v-if="
charges.some((x) => x.type === 'refund' && x.parent_charge_id === charge.id)
"
>
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
</ButtonStyled>
<ButtonStyled
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
color="red"
color-fill="text"
>
<button @click="showRefundModal(charge)">
<CurrencyIcon />
Refund options
</button>
</ButtonStyled>
</div>
</div> </div>
<button
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
class="btn"
@click="showRefundModal(charge)"
>
Refund charge
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -109,11 +196,22 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Badge, ButtonStyled, DropdownSelect, NewModal, Toggle } from "@modrinth/ui"; import { Avatar, ButtonStyled, CopyCode, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
import { formatPrice } from "@modrinth/utils"; import { formatCategory, formatPrice } from "@modrinth/utils";
import { CheckIcon, XIcon } from "@modrinth/assets"; import {
CheckIcon,
XIcon,
UserIcon,
ModrinthPlusIcon,
ServerIcon,
ExternalIcon,
CurrencyIcon,
} from "@modrinth/assets";
import dayjs from "dayjs";
import { products } from "~/generated/state.json"; import { products } from "~/generated/state.json";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
const flags = useFeatureFlags();
const route = useRoute(); const route = useRoute();
const data = useNuxtApp(); const data = useNuxtApp();
const vintl = useVIntl(); const vintl = useVIntl();
@@ -164,7 +262,10 @@ const subscriptionCharges = computed(() => {
return subscriptions.value.map((subscription) => { return subscriptions.value.map((subscription) => {
return { return {
...subscription, ...subscription,
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id), charges: charges.value
.filter((charge) => charge.subscription_id === subscription.id)
.slice()
.sort((a, b) => dayjs(b.due).diff(dayjs(a.due))),
product: products.find((product) => product: products.find((product) =>
product.prices.some((price) => price.id === subscription.price_id), product.prices.some((price) => price.id === subscription.price_id),
), ),
@@ -212,4 +313,30 @@ async function refundCharge() {
} }
refunding.value = false; refunding.value = false;
} }
const chargeStatuses = {
open: {
color: "bg-blue",
},
processing: {
color: "bg-orange",
},
succeeded: {
color: "bg-green",
},
failed: {
color: "bg-red",
},
cancelled: {
color: "bg-red",
},
};
</script> </script>
<style scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { getChangelog } from "@modrinth/utils"; import { getChangelog } from "@modrinth/utils";
import { ChangelogEntry } from "@modrinth/ui"; import { ChangelogEntry, Timeline } from "@modrinth/ui";
import { ChevronLeftIcon } from "@modrinth/assets"; import { ChevronLeftIcon } from "@modrinth/assets";
const route = useRoute(); const route = useRoute();
@@ -39,41 +39,13 @@ if (!changelogEntry.value) {
> >
<ChevronLeftIcon /> View full changelog <ChevronLeftIcon /> View full changelog
</nuxt-link> </nuxt-link>
<div class="relative flex flex-col gap-4 pb-6"> <Timeline fade-out-end :fade-out-start="!isFirst" :class="{ '-mt-8': !isFirst }">
<div class="absolute flex h-full w-4 justify-center"> <ChangelogEntry
<div class="timeline-indicator" :class="{ first: isFirst }" /> :entry="changelogEntry"
</div> :first="isFirst"
<ChangelogEntry :entry="changelogEntry" :first="isFirst" show-type class="relative z-[1]" /> show-type
</div> :class="{ 'mt-8': !isFirst }"
/>
</Timeline>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.timeline-indicator {
background-image: linear-gradient(
to bottom,
var(--color-raised-bg) 66%,
rgba(255, 255, 255, 0) 0%
);
background-size: 100% 30px;
background-repeat: repeat-y;
height: calc(100% + 2rem);
width: 4px;
margin-top: -2rem;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 8rem,
black calc(100% - 8rem),
transparent 100%
);
&.first {
margin-top: 1rem;
mask-image: linear-gradient(black calc(100% - 15rem), transparent 100%);
}
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Product, getChangelog } from "@modrinth/utils"; import { type Product, getChangelog } from "@modrinth/utils";
import { ChangelogEntry } from "@modrinth/ui"; import { ChangelogEntry } from "@modrinth/ui";
import Timeline from "@modrinth/ui/src/components/base/Timeline.vue";
import NavTabs from "~/components/ui/NavTabs.vue"; import NavTabs from "~/components/ui/NavTabs.vue";
const route = useRoute(); const route = useRoute();
@@ -51,10 +52,7 @@ const changelogEntries = computed(() =>
query="filter" query="filter"
class="mb-4" class="mb-4"
/> />
<div class="relative flex flex-col gap-4 pb-6"> <Timeline fade-out-end>
<div class="absolute flex h-full w-4 justify-center">
<div class="timeline-indicator" />
</div>
<ChangelogEntry <ChangelogEntry
v-for="(entry, index) in changelogEntries" v-for="(entry, index) in changelogEntries"
:key="entry.date" :key="entry.date"
@@ -62,25 +60,6 @@ const changelogEntries = computed(() =>
:first="index === 0" :first="index === 0"
:show-type="filter === undefined" :show-type="filter === undefined"
has-link has-link
class="relative z-[1]"
/> />
</div> </Timeline>
</template> </template>
<style lang="scss" scoped>
.timeline-indicator {
background-image: linear-gradient(
to bottom,
var(--color-raised-bg) 66%,
rgba(255, 255, 255, 0) 0%
);
background-size: 100% 30px;
background-repeat: repeat-y;
margin-top: 1rem;
height: calc(100% - 1rem);
width: 4px;
mask-image: linear-gradient(to bottom, black calc(100% - 15rem), transparent 100%);
}
</style>

View File

@@ -37,7 +37,7 @@ async function copyText() {
margin: 0; margin: 0;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
width: min-content; width: fit-content;
border-radius: 10px; border-radius: 10px;
user-select: text; user-select: text;
transition: transition:
@@ -50,12 +50,6 @@ async function copyText() {
transition: none !important; transition: none !important;
} }
span {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}
svg { svg {
width: 1em; width: 1em;
height: 1em; height: 1em;

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
withDefaults(
defineProps<{
fadeOutStart?: boolean
fadeOutEnd?: boolean
}>(),
{
fadeOutStart: false,
fadeOutEnd: false,
},
)
</script>
<template>
<div class="relative flex flex-col gap-4 pb-6 isolate">
<div class="absolute flex h-full w-4 justify-center">
<div
class="timeline-indicator"
:class="{ 'fade-out-start': fadeOutStart, 'fade-out-end': fadeOutEnd }"
/>
</div>
<slot />
</div>
</template>
<style lang="scss" scoped>
.timeline-indicator {
background-image: linear-gradient(
to bottom,
var(--timeline-line-color, var(--color-raised-bg)) 66%,
rgba(255, 255, 255, 0) 0%
);
background-size: 100% 30px;
background-repeat: repeat-y;
margin-top: 1rem;
height: calc(100% - 1rem);
width: 4px;
z-index: -1;
&.fade-out-start {
mask-image: linear-gradient(to top, black calc(100% - 15rem), transparent 100%);
}
&.fade-out-end {
mask-image: linear-gradient(to bottom, black calc(100% - 15rem), transparent 100%);
}
&.fade-out-start.fade-out-end {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 8rem,
black calc(100% - 8rem),
transparent 100%
);
}
}
</style>

View File

@@ -35,6 +35,7 @@ export { default as Slider } from './base/Slider.vue'
export { default as StatItem } from './base/StatItem.vue' export { default as StatItem } from './base/StatItem.vue'
export { default as TagItem } from './base/TagItem.vue' export { default as TagItem } from './base/TagItem.vue'
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue' export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'
export { default as Timeline } from './base/Timeline.vue'
export { default as Toggle } from './base/Toggle.vue' export { default as Toggle } from './base/Toggle.vue'
// Branding // Branding

View File

@@ -90,7 +90,7 @@ function addBodyPadding() {
} }
function show(event?: MouseEvent) { function show(event?: MouseEvent) {
props.onShow() props.onShow?.()
open.value = true open.value = true
addBodyPadding() addBodyPadding()