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>
</TeleportOverflowMenu>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="
@@ -252,14 +251,52 @@
</ButtonStyled>
</template>
</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">
<OverflowMenu
v-if="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="createPopoutId"
:dropdown-id="`${basePopoutId}-create`"
aria-label="Create new..."
:options="[
{
@@ -291,7 +328,7 @@
</ButtonStyled>
<OverflowMenu
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"
:options="userMenuOptions"
>
@@ -312,7 +349,7 @@
</template>
<template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </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>
</OverflowMenu>
<template v-else>
@@ -399,7 +436,7 @@
class="iconified-button"
to="/moderation"
>
<ModerationIcon aria-hidden="true" />
<ScaleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.moderationLabel) }}
</NuxtLink>
<NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags">
@@ -460,7 +497,7 @@
}
"
>
<NotificationIcon aria-hidden="true" />
<BellIcon aria-hidden="true" />
</NuxtLink>
<NuxtLink
to="/dashboard"
@@ -479,7 +516,7 @@
>
<template v-if="!auth.user">
<HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" />
<CrossIcon v-else aria-hidden="true" />
<XIcon v-else aria-hidden="true" />
</template>
<template v-else>
<Avatar
@@ -589,6 +626,7 @@
</template>
<script setup>
import {
ModrinthIcon,
ArrowBigUpDashIcon,
BookmarkIcon,
ServerIcon,
@@ -626,17 +664,17 @@ import {
TwitterIcon,
MastodonIcon,
GitHubIcon,
XIcon as CrossIcon,
ScaleIcon as ModerationIcon,
BellIcon as NotificationIcon,
ScaleIcon,
} from "@modrinth/assets";
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
import { isAdmin, isStaff } from "@modrinth/utils";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import OrganizationCreateModal from "~/components/ui/OrganizationCreateModal.vue";
import TeleportOverflowMenu from "~/components/ui/servers/TeleportOverflowMenu.vue";
import Report from "~/pages/report.vue";
const { formatMessage } = useVIntl();
@@ -652,8 +690,7 @@ const route = useNativeRoute();
const router = useNativeRouter();
const link = config.public.siteUrl + route.path.replace(/\/+$/, "");
const createPopoutId = useId();
const userPopoutId = useId();
const basePopoutId = useId();
const verifyEmailBannerMessages = defineMessages({
title: {

View File

@@ -58,50 +58,137 @@
</div>
</div>
</NewModal>
<div class="normal-page no-sidebar">
<h1>{{ user.username }}'s subscriptions</h1>
<div class="normal-page__content">
<div class="page experimental-styles-within">
<div
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">
<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 class="mb-4 grid grid-cols-[1fr_auto]">
<div>
<span class="flex items-center gap-2 font-semibold text-contrast">
<template v-if="subscription.product.metadata.type === 'midas'">
<ModrinthPlusIcon class="h-7 w-min" />
</template>
<template v-else-if="subscription.product.metadata.type === 'pyro'">
<ModrinthServersIcon class="h-7 w-min" />
</template>
<template v-else> Unknown product </template>
</span>
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
{{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
dayjs(subscription.created).fromNow()
}})
</div>
</div>
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
<nuxt-link
:to="`/servers/manage/${subscription.metadata.id}`"
target="_blank"
class="w-fit"
>
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
<CopyCode :text="subscription.metadata.id" />
</div>
</div>
<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>
<button
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
class="btn"
@click="showRefundModal(charge)"
>
Refund charge
</button>
</div>
</div>
</div>
@@ -109,11 +196,22 @@
</div>
</template>
<script setup>
import { Badge, ButtonStyled, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
import { formatPrice } from "@modrinth/utils";
import { CheckIcon, XIcon } from "@modrinth/assets";
import { Avatar, ButtonStyled, CopyCode, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
import { formatCategory, formatPrice } from "@modrinth/utils";
import {
CheckIcon,
XIcon,
UserIcon,
ModrinthPlusIcon,
ServerIcon,
ExternalIcon,
CurrencyIcon,
} from "@modrinth/assets";
import dayjs from "dayjs";
import { products } from "~/generated/state.json";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
const flags = useFeatureFlags();
const route = useRoute();
const data = useNuxtApp();
const vintl = useVIntl();
@@ -164,7 +262,10 @@ const subscriptionCharges = computed(() => {
return subscriptions.value.map((subscription) => {
return {
...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.prices.some((price) => price.id === subscription.price_id),
),
@@ -212,4 +313,30 @@ async function refundCharge() {
}
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>
<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">
import { getChangelog } from "@modrinth/utils";
import { ChangelogEntry } from "@modrinth/ui";
import { ChangelogEntry, Timeline } from "@modrinth/ui";
import { ChevronLeftIcon } from "@modrinth/assets";
const route = useRoute();
@@ -39,41 +39,13 @@ if (!changelogEntry.value) {
>
<ChevronLeftIcon /> View full changelog
</nuxt-link>
<div class="relative flex flex-col gap-4 pb-6">
<div class="absolute flex h-full w-4 justify-center">
<div class="timeline-indicator" :class="{ first: isFirst }" />
</div>
<ChangelogEntry :entry="changelogEntry" :first="isFirst" show-type class="relative z-[1]" />
</div>
<Timeline fade-out-end :fade-out-start="!isFirst" :class="{ '-mt-8': !isFirst }">
<ChangelogEntry
:entry="changelogEntry"
:first="isFirst"
show-type
:class="{ 'mt-8': !isFirst }"
/>
</Timeline>
</div>
</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">
import { type Product, getChangelog } from "@modrinth/utils";
import { ChangelogEntry } from "@modrinth/ui";
import Timeline from "@modrinth/ui/src/components/base/Timeline.vue";
import NavTabs from "~/components/ui/NavTabs.vue";
const route = useRoute();
@@ -51,10 +52,7 @@ const changelogEntries = computed(() =>
query="filter"
class="mb-4"
/>
<div class="relative flex flex-col gap-4 pb-6">
<div class="absolute flex h-full w-4 justify-center">
<div class="timeline-indicator" />
</div>
<Timeline fade-out-end>
<ChangelogEntry
v-for="(entry, index) in changelogEntries"
:key="entry.date"
@@ -62,25 +60,6 @@ const changelogEntries = computed(() =>
:first="index === 0"
:show-type="filter === undefined"
has-link
class="relative z-[1]"
/>
</div>
</Timeline>
</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;
padding: 0.25rem 0.5rem;
background-color: var(--color-button-bg);
width: min-content;
width: fit-content;
border-radius: 10px;
user-select: text;
transition:
@@ -50,12 +50,6 @@ async function copyText() {
transition: none !important;
}
span {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}
svg {
width: 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 TagItem } from './base/TagItem.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'
// Branding

View File

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