You've already forked AstralRinth
Merge tag 'v0.14.6' into beta
v0.14.6
This commit is contained in:
@@ -1,36 +1,19 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtRouteAnnouncer />
|
||||
<ModrinthLoadingIndicator />
|
||||
<ClientOnly>
|
||||
<LoadingBar />
|
||||
</ClientOnly>
|
||||
<NotificationPanel />
|
||||
<I18nDebugPanel />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NotificationPanel,
|
||||
provideModrinthClient,
|
||||
provideNotificationManager,
|
||||
providePageContext,
|
||||
} from '@modrinth/ui'
|
||||
import { I18nDebugPanel, LoadingBar, NotificationPanel } from '@modrinth/ui'
|
||||
|
||||
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
|
||||
import { createModrinthClient } from '~/helpers/api.ts'
|
||||
import { FrontendNotificationManager } from '~/providers/frontend-notifications.ts'
|
||||
import { setupProviders } from '~/providers/setup.ts'
|
||||
|
||||
const auth = await useAuth()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
provideNotificationManager(new FrontendNotificationManager())
|
||||
|
||||
const client = createModrinthClient(auth, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
provideModrinthClient(client)
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(false),
|
||||
showAds: ref(false),
|
||||
})
|
||||
setupProviders(auth)
|
||||
</script>
|
||||
|
||||
@@ -1,74 +1,18 @@
|
||||
/*
|
||||
Base components
|
||||
*/
|
||||
|
||||
.known-error .multiselect__tags {
|
||||
border-color: var(--color-red) !important;
|
||||
background-color: var(--color-warning-bg) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-display {
|
||||
display: grid;
|
||||
grid-gap: var(--spacing-card-md);
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
|
||||
.grid-display__item {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--size-rounded-card);
|
||||
padding: var(--spacing-card-lg);
|
||||
gap: var(--spacing-card-md);
|
||||
outline: 1px solid transparent;
|
||||
|
||||
.label {
|
||||
color: var(--color-heading);
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-dark);
|
||||
font-weight: bold;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.goto-link {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.width-12 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
&.width-16 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Cards and body styling
|
||||
*/
|
||||
// CARDS
|
||||
.base-card {
|
||||
@extend .padding-lg;
|
||||
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
min-height: var(--font-size-2xl);
|
||||
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--size-rounded-card);
|
||||
background-color: var(--surface-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--surface-4);
|
||||
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
margin-bottom: var(--gap-md);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
|
||||
box-shadow: var(--shadow-card);
|
||||
|
||||
.card__overlay {
|
||||
position: absolute;
|
||||
@@ -81,6 +25,17 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:where(&.warning, &.information) {
|
||||
padding: 1.5rem;
|
||||
line-height: 1.5;
|
||||
min-height: 0;
|
||||
|
||||
a {
|
||||
color: var(--color-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.moderation-card {
|
||||
background-color: var(--color-warning-banner-bg);
|
||||
}
|
||||
@@ -99,7 +54,7 @@
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
|
||||
// Same styling as h3
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.17rem;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -129,81 +84,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.padding-lg {
|
||||
padding: var(--spacing-card-lg);
|
||||
}
|
||||
|
||||
.padding-bg {
|
||||
padding: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
.padding-md {
|
||||
padding: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.padding-sm {
|
||||
padding: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.padding-0 {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.padding-block-lg {
|
||||
padding-block: var(--spacing-card-lg);
|
||||
}
|
||||
|
||||
.padding-block-bg {
|
||||
padding-block: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
.padding-block-md {
|
||||
padding-block: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.padding-block-sm {
|
||||
padding-block: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.padding-block-0 {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.padding-inline-lg {
|
||||
padding-inline: var(--spacing-card-lg);
|
||||
}
|
||||
|
||||
.padding-inline-bg {
|
||||
padding-inline: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
.padding-inline-md {
|
||||
padding-inline: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.padding-inline-sm {
|
||||
padding-inline: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.padding-inline-0 {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.universal-body {
|
||||
@extend .universal-labels;
|
||||
|
||||
.multiselect {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
> :where(
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
> :where(input + *, .input-group + *, .chips + *, .input-div + *) {
|
||||
&:not(:empty) {
|
||||
margin-block-start: var(--spacing-card-md);
|
||||
}
|
||||
@@ -223,10 +107,7 @@
|
||||
:where(input) {
|
||||
box-sizing: border-box;
|
||||
max-height: 40px;
|
||||
|
||||
&:not(.stylized-toggle) {
|
||||
max-width: 100%;
|
||||
}
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:where(.adjacent-input, &.adjacent-input) {
|
||||
@@ -271,10 +152,6 @@
|
||||
&:not(&.small) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.stylized-toggle {
|
||||
flex-basis: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,19 +231,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-card {
|
||||
@extend .base-card;
|
||||
@extend .padding-inline-lg;
|
||||
@extend .padding-block-md;
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.5rem;
|
||||
min-height: 3.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
Other
|
||||
*/
|
||||
@@ -386,19 +250,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.title-link {
|
||||
text-decoration: underline;
|
||||
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.button-base {
|
||||
@extend .button-animation;
|
||||
font-weight: 500;
|
||||
@@ -469,36 +320,6 @@ tr.button-transparent {
|
||||
}
|
||||
}
|
||||
|
||||
.button-within {
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
filter 0.2s ease-in-out,
|
||||
transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
&:focus-visible:not(&.disabled),
|
||||
&:hover:not(&.disabled) {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
|
||||
&:active:not(&.disabled) {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
|
||||
&disabled,
|
||||
&[disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button-color-base {
|
||||
box-sizing: border-box;
|
||||
--text-color: var(--color-button-text);
|
||||
@@ -550,10 +371,6 @@ tr.button-transparent {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.bold-button {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.square-button {
|
||||
@@ -584,24 +401,6 @@ tr.button-transparent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
@extend .iconified-button;
|
||||
|
||||
box-sizing: content-box;
|
||||
min-height: unset;
|
||||
border-radius: var(--size-rounded-max);
|
||||
white-space: nowrap;
|
||||
padding: 0.5rem 0.75rem;
|
||||
max-height: 1.75rem;
|
||||
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
margin-right: 0.5rem;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.raised-button {
|
||||
--background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
|
||||
@@ -622,11 +421,6 @@ tr.button-transparent {
|
||||
--text-color: var(--color-brand-inverted);
|
||||
}
|
||||
|
||||
.alt-brand-button {
|
||||
--background-color: var(--color-brand-highlight);
|
||||
--text-color: var(--color-text);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
grid-gap: var(--spacing-card-sm);
|
||||
@@ -635,104 +429,6 @@ tr.button-transparent {
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
.multiselect--above .multiselect__content-wrapper {
|
||||
border-top: none !important;
|
||||
border-top-left-radius: var(--size-rounded-card) !important;
|
||||
border-top-right-radius: var(--size-rounded-card) !important;
|
||||
}
|
||||
|
||||
.known-error .multiselect__tags {
|
||||
border-color: var(--color-red) !important;
|
||||
background-color: var(--color-warning-bg) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
//outline: 0; Bad for accessibility
|
||||
}
|
||||
}
|
||||
|
||||
.stylized-toggle {
|
||||
@extend .button-base;
|
||||
|
||||
box-sizing: content-box;
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
width: 52px;
|
||||
max-width: 52px;
|
||||
border-radius: var(--size-rounded-max);
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--color-button-bg);
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-toggle-handle);
|
||||
transition: all 0.2s cubic-bezier(0.5, 0.1, 0.75, 1.35);
|
||||
outline: 2px solid transparent;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background-color: var(--color-brand);
|
||||
|
||||
&:after {
|
||||
transform: translatex(20px);
|
||||
background: var(--color-brand-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &:focus {
|
||||
background: var(--color-button-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
textarea {
|
||||
border-radius: var(--size-rounded-sm);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.resizable-textarea-wrapper {
|
||||
display: block;
|
||||
|
||||
textarea {
|
||||
border-radius: var(--size-rounded-sm);
|
||||
min-height: 10rem;
|
||||
width: calc(100% - var(--spacing-card-lg) - var(--spacing-card-sm));
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -804,36 +500,6 @@ textarea.known-error {
|
||||
color: var(--color-link-active);
|
||||
}
|
||||
|
||||
h1 {
|
||||
.beta-badge {
|
||||
font-size: 0.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.beta-badge {
|
||||
font-size: 0.7em;
|
||||
padding: 0.2rem 0.4rem;
|
||||
background-color: transparent;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid var(--color-text);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--size-rounded-max);
|
||||
margin-left: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.router-link-exact-active,
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
.beta-badge {
|
||||
background-color: var(--color-button-text-active);
|
||||
box-sizing: border-box;
|
||||
border-color: transparent;
|
||||
color: var(--color-raised-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.button-animation,
|
||||
button {
|
||||
@@ -848,7 +514,6 @@ h3 {
|
||||
}
|
||||
|
||||
.full-width-inputs {
|
||||
.multiselect,
|
||||
input,
|
||||
.iconified-input {
|
||||
width: 100%;
|
||||
@@ -871,10 +536,6 @@ button {
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
|
||||
.multiselect {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex-shrink: 2;
|
||||
}
|
||||
@@ -896,20 +557,6 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
.input-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
> .multiselect {
|
||||
width: unset;
|
||||
height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.text-input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -938,6 +585,7 @@ button {
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
@@ -960,30 +608,6 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
.primary-stat {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: 0.6rem;
|
||||
|
||||
.primary-stat__icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.primary-stat__text {
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.primary-stat__counter {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.no-margin {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.project-list {
|
||||
width: 100%;
|
||||
gap: var(--spacing-card-md);
|
||||
@@ -1113,181 +737,87 @@ a.iconified-link {
|
||||
}
|
||||
}
|
||||
|
||||
a.subtle-link {
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
filter: var(--hover-filter);
|
||||
}
|
||||
|
||||
&:active {
|
||||
filter: var(--active-filter);
|
||||
}
|
||||
}
|
||||
|
||||
.inline-svg svg,
|
||||
svg.inline-svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// START STUFF FOR OMORPHIA
|
||||
.experimental-styles-within {
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-4);
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-4);
|
||||
}
|
||||
|
||||
.flex-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-12);
|
||||
padding: var(--gap-16);
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-18);
|
||||
font-weight: var(--weight-extrabold);
|
||||
color: var(--color-contrast);
|
||||
line-height: initial;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag-list__item {
|
||||
background-color: var(--_bg-color, var(--color-button-bg));
|
||||
padding: var(--gap-4) var(--gap-8);
|
||||
border-radius: var(--radius-max);
|
||||
h3 {
|
||||
font-size: var(--text-16);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--text-14);
|
||||
display: flex;
|
||||
gap: var(--gap-4);
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
color: var(--_color, var(--color-secondary));
|
||||
|
||||
svg {
|
||||
width: var(--icon-14);
|
||||
height: var(--icon-14);
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-8);
|
||||
padding-left: var(--gap-6);
|
||||
|
||||
color: var(--color-base);
|
||||
font-weight: var(--weight-bold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-4);
|
||||
|
||||
svg {
|
||||
width: var(--icon-16);
|
||||
height: var(--icon-16);
|
||||
margin-right: var(--gap-4);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-secondary);
|
||||
font-style: italic;
|
||||
font-weight: var(--weight-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.status-list__item--color-green svg {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.status-list__item--color-orange svg {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.status-list__item--color-red svg {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.status-list__item--color-blue svg {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.status-list__item--color-purple svg {
|
||||
color: var(--color-purple);
|
||||
}
|
||||
|
||||
&.flex-card,
|
||||
.flex-card {
|
||||
> section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-12);
|
||||
padding: var(--gap-16) var(--gap-24);
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-18);
|
||||
font-weight: var(--weight-extrabold);
|
||||
color: var(--color-contrast);
|
||||
line-height: initial;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-16);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-base);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-8);
|
||||
}
|
||||
}
|
||||
|
||||
.list-style {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-12);
|
||||
font-weight: var(--weight-bold);
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
border-color: var(--color-button-border);
|
||||
margin-block: var(--gap-2);
|
||||
}
|
||||
}
|
||||
|
||||
.iconified-list-item {
|
||||
display: flex;
|
||||
gap: var(--gap-8);
|
||||
vertical-align: middle;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
width: var(--icon-16);
|
||||
height: var(--icon-16);
|
||||
}
|
||||
.list-style {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-12);
|
||||
font-weight: var(--weight-bold);
|
||||
|
||||
> svg:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
hr {
|
||||
width: 100%;
|
||||
border-color: var(--color-button-border);
|
||||
margin-block: var(--gap-2);
|
||||
}
|
||||
}
|
||||
|
||||
.iconified-list-item {
|
||||
display: flex;
|
||||
gap: var(--gap-8);
|
||||
vertical-align: middle;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
|
||||
svg {
|
||||
width: var(--icon-16);
|
||||
height: var(--icon-16);
|
||||
}
|
||||
|
||||
.links-list {
|
||||
@extend .list-style;
|
||||
|
||||
> a {
|
||||
@extend .iconified-list-item;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
> svg:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.details-list {
|
||||
@extend .list-style;
|
||||
}
|
||||
.details-list {
|
||||
@extend .list-style;
|
||||
}
|
||||
|
||||
.details-list__item {
|
||||
@extend .iconified-list-item;
|
||||
.details-list__item {
|
||||
@extend .iconified-list-item;
|
||||
|
||||
.details-list__item__text--style-secondary {
|
||||
color: var(--color-secondary);
|
||||
font-weight: var(--weight-normal);
|
||||
font-size: var(--text-14);
|
||||
}
|
||||
.details-list__item__text--style-secondary {
|
||||
color: var(--color-secondary);
|
||||
font-weight: var(--weight-normal);
|
||||
font-size: var(--text-14);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@modrinth/assets/styles/reset.scss';
|
||||
|
||||
html {
|
||||
--dark-color-text: #b0bac5;
|
||||
--dark-color-text-dark: #ecf9fb;
|
||||
@@ -60,8 +62,6 @@ html {
|
||||
--color-button-bg-active: #c3c6cb;
|
||||
--color-button-text-active: var(--color-button-text-hover);
|
||||
|
||||
--color-toggle-handle: var(--color-icon);
|
||||
|
||||
--color-dropdown-bg: var(--color-button-bg);
|
||||
--color-dropdown-text: var(--color-button-text);
|
||||
|
||||
@@ -177,8 +177,6 @@ html {
|
||||
--color-button-bg-active: #616570;
|
||||
--color-button-text-active: var(--color-button-text-hover);
|
||||
|
||||
--color-toggle-handle: var(--color-button-text);
|
||||
|
||||
--color-dropdown-bg: var(--color-button-bg);
|
||||
--color-dropdown-text: var(--color-button-text);
|
||||
|
||||
@@ -384,18 +382,18 @@ a {
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
input {
|
||||
@@ -450,13 +448,6 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input[type='button'] {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background-color: var(--color-code-bg);
|
||||
color: var(--color-code-text);
|
||||
@@ -467,17 +458,9 @@ kbd {
|
||||
font-size: 0.85em !important;
|
||||
}
|
||||
|
||||
@import '~/assets/styles/layout.scss';
|
||||
@import '~/assets/styles/utils.scss';
|
||||
@import '~/assets/styles/components.scss';
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[tabindex='0']:focus-visible,
|
||||
[type='button']:focus-visible {
|
||||
outline: 0.25rem solid #ea80ff;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@import './layout.scss';
|
||||
@import './utils.scss';
|
||||
@import './components.scss';
|
||||
|
||||
// OMORPHIA FIXES
|
||||
.card {
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.normal-page__sidebar {
|
||||
@@ -62,6 +63,8 @@
|
||||
|
||||
.normal-page__content {
|
||||
grid-area: content;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.normal-page__header {
|
||||
@@ -116,6 +119,8 @@
|
||||
}
|
||||
|
||||
.normal-page__content {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: calc(80rem - 18.75rem - 1.5rem);
|
||||
//overflow-x: hidden;
|
||||
}
|
||||
@@ -164,6 +169,8 @@
|
||||
|
||||
.normal-page__content {
|
||||
grid-area: content;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: calc(80rem - 18.75rem - 1.5rem);
|
||||
//overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="analytics-loading-bar" :style="{ opacity: isVisible ? 1 : 0 }" aria-hidden="true">
|
||||
<div
|
||||
class="analytics-loading-bar__track"
|
||||
:style="{
|
||||
width: `${progress}%`,
|
||||
transition: !isTransitioning
|
||||
? 'none'
|
||||
: isFinishing
|
||||
? 'width 0.1s ease-in-out'
|
||||
: isCreeping
|
||||
? 'width 2s linear'
|
||||
: 'width 0.9s ease-in-out',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const progress = ref(0)
|
||||
const isVisible = ref(false)
|
||||
const isFinishing = ref(false)
|
||||
const isCreeping = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
|
||||
let startFrame: number | null = null
|
||||
let showFrame: number | null = null
|
||||
let creepTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let resetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (showFrame !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(showFrame)
|
||||
}
|
||||
if (startFrame !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(startFrame)
|
||||
}
|
||||
if (creepTimeout) clearTimeout(creepTimeout)
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
if (resetTimeout) clearTimeout(resetTimeout)
|
||||
showFrame = null
|
||||
startFrame = null
|
||||
creepTimeout = null
|
||||
hideTimeout = null
|
||||
resetTimeout = null
|
||||
}
|
||||
|
||||
function start() {
|
||||
clearTimers()
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = false
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
progress.value = 98
|
||||
return
|
||||
}
|
||||
|
||||
showFrame = window.requestAnimationFrame(() => {
|
||||
isVisible.value = true
|
||||
showFrame = null
|
||||
startFrame = window.requestAnimationFrame(() => {
|
||||
isTransitioning.value = true
|
||||
progress.value = 85
|
||||
startFrame = null
|
||||
})
|
||||
})
|
||||
creepTimeout = setTimeout(() => {
|
||||
isCreeping.value = true
|
||||
progress.value = 98
|
||||
creepTimeout = null
|
||||
}, 900)
|
||||
}
|
||||
|
||||
function finish() {
|
||||
clearTimers()
|
||||
isVisible.value = true
|
||||
isFinishing.value = true
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = true
|
||||
progress.value = 100
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hideTimeout = setTimeout(() => {
|
||||
isVisible.value = false
|
||||
resetTimeout = setTimeout(() => {
|
||||
isTransitioning.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
}, 400)
|
||||
}, 350)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
if (loading) {
|
||||
start()
|
||||
} else if (
|
||||
isVisible.value ||
|
||||
progress.value > 0 ||
|
||||
showFrame !== null ||
|
||||
startFrame !== null ||
|
||||
creepTimeout !== null
|
||||
) {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(clearTimers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-loading-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.analytics-loading-bar__track {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--loading-bar-gradient);
|
||||
}
|
||||
</style>
|
||||
+1024
File diff suppressed because it is too large
Load Diff
+78
@@ -0,0 +1,78 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
export const ANALYTICS_DASHBOARD_STATS: readonly AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
export const TOP_GRAPH_DATASET_LIMIT = 8
|
||||
export const GRAPH_RENDER_DATASET_LIMIT = 250
|
||||
export const PREVIOUS_PERIOD_DATASET_ID_PREFIX = 'previous-period:'
|
||||
export const PREVIOUS_PERIOD_BORDER_DASH = [6, 4]
|
||||
export const PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
export const ALL_PROJECTS_DATASET_ID = 'all'
|
||||
|
||||
export const PROJECT_EVENT_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
export const MONETIZATION_LEGEND_ENTRY_ORDER = new Map([
|
||||
['breakdown:monetized', 0],
|
||||
['breakdown:unmonetized', 1],
|
||||
])
|
||||
|
||||
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES = [
|
||||
'approved',
|
||||
'unlisted',
|
||||
'private',
|
||||
] as const satisfies readonly Labrinth.Projects.v2.ProjectStatus[]
|
||||
|
||||
export type VisibleProjectStatusChangeEventStatus =
|
||||
(typeof VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)[number]
|
||||
|
||||
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
|
||||
new Set<Labrinth.Projects.v2.ProjectStatus>(VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)
|
||||
|
||||
export const LIGHT_LEGEND_PALETTE = [
|
||||
'hsl(152, 100%, 34%)',
|
||||
'hsl(26, 100%, 42%)',
|
||||
'hsl(202, 100%, 35%)',
|
||||
'hsl(327, 45%, 64%)',
|
||||
'hsl(41, 100%, 45%)',
|
||||
'hsl(250, 60%, 33%)',
|
||||
'hsl(170, 43%, 47%)',
|
||||
'hsl(330, 60%, 33%)',
|
||||
'hsl(46, 100%, 36%)',
|
||||
'hsl(167, 100%, 30%)',
|
||||
'hsl(343, 38%, 45%)',
|
||||
'hsl(222, 100%, 28%)',
|
||||
'hsl(270, 62%, 60%)',
|
||||
'hsl(32, 100%, 37%)',
|
||||
'hsl(349, 57%, 51%)',
|
||||
'hsl(191, 43%, 37%)',
|
||||
]
|
||||
|
||||
export const DARK_LEGEND_PALETTE = [
|
||||
'hsl(145, 78%, 48%)',
|
||||
'hsl(41, 100%, 50%)',
|
||||
'hsl(202, 77%, 63%)',
|
||||
'hsl(323, 66%, 72%)',
|
||||
'hsl(56, 85%, 60%)',
|
||||
'hsl(255, 92%, 80%)',
|
||||
'hsl(12, 100%, 67%)',
|
||||
'hsl(176, 58%, 56%)',
|
||||
'hsl(60, 100%, 41%)',
|
||||
'hsl(165, 80%, 38%)',
|
||||
'hsl(341, 36%, 56%)',
|
||||
'hsl(226, 60%, 49%)',
|
||||
'hsl(252, 53%, 62%)',
|
||||
'hsl(75, 59%, 50%)',
|
||||
'hsl(195, 56%, 42%)',
|
||||
'hsl(30, 59%, 56%)',
|
||||
]
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<Menu
|
||||
theme="analytics-controls-menu"
|
||||
placement="bottom-end"
|
||||
:shown="isControlsMenuOpen"
|
||||
:triggers="[]"
|
||||
:popper-triggers="[]"
|
||||
:aria-id="controlsMenuId"
|
||||
no-auto-focus
|
||||
@update:shown="isControlsMenuOpen = $event"
|
||||
>
|
||||
<button
|
||||
ref="controlsMenuTrigger"
|
||||
type="button"
|
||||
:aria-expanded="isControlsMenuOpen"
|
||||
:aria-controls="controlsMenuId"
|
||||
:aria-label="
|
||||
formatMessage(analyticsChartMessages.controlsAria, {
|
||||
activeCount: activeControlCountLabel,
|
||||
})
|
||||
"
|
||||
class="btn-dropdown-animation inline-flex min-h-5 cursor-pointer items-center justify-between gap-2 rounded-xl border-0 bg-surface-4 px-3 py-2 text-left text-sm font-semibold text-button-text shadow-none transition-all duration-200 hover:brightness-[115%] focus-visible:brightness-[115%] active:brightness-[115%]"
|
||||
@click="toggleControlsMenu"
|
||||
>
|
||||
<Settings2Icon class="size-4 text-secondary" aria-hidden="true" />
|
||||
<span class="leading-tight text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.controlsButton) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="activeControlCount > 0"
|
||||
class="inline-flex min-w-5 items-center justify-center rounded-full bg-highlight-green px-1.5 text-xs font-semibold leading-5 text-green"
|
||||
>
|
||||
{{ activeControlCount }}
|
||||
</span>
|
||||
<DropdownIcon class="size-4 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div
|
||||
ref="controlsMenuPanel"
|
||||
role="dialog"
|
||||
:aria-label="formatMessage(analyticsChartMessages.controlsDialogAria)"
|
||||
class="mt-1 flex w-[228px] max-w-[calc(100vw_-_2rem)] flex-col overflow-hidden rounded-[14px] border border-solid border-surface-4 bg-surface-3 text-sm shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-2.5 text-xs font-medium">
|
||||
<span class="font-semibold text-primary">{{ activeControlCountLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isResetDisabled"
|
||||
class="border-0 bg-transparent p-0 text-xs font-semibold text-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="isResetDisabled ? '' : 'hover:text-contrast focus-visible:text-contrast'"
|
||||
@click="resetControls"
|
||||
>
|
||||
{{ formatMessage(analyticsMessages.resetButton) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasDisplayControls"
|
||||
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
|
||||
>
|
||||
<div class="mb-0.5 text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.displayControls) }}
|
||||
</div>
|
||||
<div v-if="canShowPreviousPeriod" class="flex min-h-7 items-center justify-between">
|
||||
<label
|
||||
:for="previousPeriodToggleId"
|
||||
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
>
|
||||
<HistoryIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.previousPeriod) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="previousPeriodToggleId"
|
||||
v-model="showPreviousPeriodModel"
|
||||
:small="smallToggles"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="canUseRatioMode" class="flex min-h-7 items-center justify-between">
|
||||
<label
|
||||
:for="ratioModeToggleId"
|
||||
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-4 shrink-0 items-center justify-center text-sm font-semibold leading-none text-secondary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.ratio) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle :id="ratioModeToggleId" v-model="ratioModeModel" :small="smallToggles" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
|
||||
>
|
||||
<div class="mb-0.5 text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.annotations) }}
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="projectEventsDisabledTooltip"
|
||||
class="justify3 flex min-h-7 items-center"
|
||||
:aria-disabled="!hasProjectEvents"
|
||||
>
|
||||
<label
|
||||
:for="projectEventsToggleId"
|
||||
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
:class="hasProjectEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<TagCategoryFlagIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.projectEvents) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="projectEventsToggleId"
|
||||
v-model="showProjectEventsControlModel"
|
||||
:small="smallToggles"
|
||||
:disabled="!hasProjectEvents"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="modrinthEventsDisabledTooltip"
|
||||
class="justify3 flex min-h-7 items-center"
|
||||
:aria-disabled="!hasChartEvents"
|
||||
>
|
||||
<label
|
||||
:for="modrinthEventsToggleId"
|
||||
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
:class="hasChartEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<InfoIcon class="size-4 shrink-0 text-blue" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.modrinthEvents) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="modrinthEventsToggleId"
|
||||
v-model="showChartEventsControlModel"
|
||||
:small="smallToggles"
|
||||
:disabled="!hasChartEvents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownIcon,
|
||||
HistoryIcon,
|
||||
InfoIcon,
|
||||
Settings2Icon,
|
||||
TagCategoryFlagIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { Menu } from 'floating-vue'
|
||||
|
||||
import { analyticsChartMessages, analyticsMessages } from '../../analytics-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
ratioMode: boolean
|
||||
showChartEvents: boolean
|
||||
showProjectEvents: boolean
|
||||
showPreviousPeriod: boolean
|
||||
canUseRatioMode: boolean
|
||||
canShowPreviousPeriod: boolean
|
||||
hasChartEvents: boolean
|
||||
hasProjectEvents: boolean
|
||||
smallToggles: boolean
|
||||
defaultRatioMode: boolean
|
||||
defaultShowChartEvents: boolean
|
||||
defaultShowProjectEvents: boolean
|
||||
defaultShowPreviousPeriod: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e:
|
||||
| 'update:ratioMode'
|
||||
| 'update:showChartEvents'
|
||||
| 'update:showProjectEvents'
|
||||
| 'update:showPreviousPeriod',
|
||||
value: boolean,
|
||||
): void
|
||||
}>()
|
||||
|
||||
const isControlsMenuOpen = ref(false)
|
||||
const controlsMenuTrigger = ref<HTMLElement | null>(null)
|
||||
const controlsMenuPanel = ref<HTMLElement | null>(null)
|
||||
const controlsMenuId = useId()
|
||||
const ratioModeToggleId = useId()
|
||||
const previousPeriodToggleId = useId()
|
||||
const modrinthEventsToggleId = useId()
|
||||
const projectEventsToggleId = useId()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const ratioModeModel = computed({
|
||||
get: () => props.ratioMode,
|
||||
set: (value: boolean) => emit('update:ratioMode', value),
|
||||
})
|
||||
const showChartEventsModel = computed({
|
||||
get: () => props.showChartEvents,
|
||||
set: (value: boolean) => emit('update:showChartEvents', value),
|
||||
})
|
||||
const showChartEventsControlModel = computed({
|
||||
get: () => props.hasChartEvents && props.showChartEvents,
|
||||
set: (value: boolean) => emit('update:showChartEvents', value),
|
||||
})
|
||||
const showProjectEventsModel = computed({
|
||||
get: () => props.showProjectEvents,
|
||||
set: (value: boolean) => emit('update:showProjectEvents', value),
|
||||
})
|
||||
const showProjectEventsControlModel = computed({
|
||||
get: () => props.hasProjectEvents && props.showProjectEvents,
|
||||
set: (value: boolean) => emit('update:showProjectEvents', value),
|
||||
})
|
||||
const showPreviousPeriodModel = computed({
|
||||
get: () => props.showPreviousPeriod,
|
||||
set: (value: boolean) => emit('update:showPreviousPeriod', value),
|
||||
})
|
||||
|
||||
const hasDisplayControls = computed(() => props.canShowPreviousPeriod || props.canUseRatioMode)
|
||||
const projectEventsDisabledTooltip = computed(() =>
|
||||
props.hasProjectEvents ? undefined : formatMessage(analyticsChartMessages.noProjectEvents),
|
||||
)
|
||||
const modrinthEventsDisabledTooltip = computed(() =>
|
||||
props.hasChartEvents ? undefined : formatMessage(analyticsChartMessages.noModrinthEvents),
|
||||
)
|
||||
const activeControlCount = computed(() => {
|
||||
let count = 0
|
||||
if (props.canShowPreviousPeriod && props.showPreviousPeriod) count += 1
|
||||
if (props.canUseRatioMode && props.ratioMode) count += 1
|
||||
if (props.hasProjectEvents && props.showProjectEvents) count += 1
|
||||
if (props.hasChartEvents && props.showChartEvents) count += 1
|
||||
return count
|
||||
})
|
||||
const activeControlCountLabel = computed(() =>
|
||||
formatMessage(analyticsChartMessages.activeControlCount, { count: activeControlCount.value }),
|
||||
)
|
||||
const isResetDisabled = computed(
|
||||
() =>
|
||||
props.showPreviousPeriod === props.defaultShowPreviousPeriod &&
|
||||
props.ratioMode === props.defaultRatioMode &&
|
||||
props.showProjectEvents === props.defaultShowProjectEvents &&
|
||||
props.showChartEvents === props.defaultShowChartEvents,
|
||||
)
|
||||
|
||||
function toggleControlsMenu() {
|
||||
isControlsMenuOpen.value = !isControlsMenuOpen.value
|
||||
}
|
||||
|
||||
function resetControls() {
|
||||
if (isResetDisabled.value) return
|
||||
|
||||
showPreviousPeriodModel.value = props.defaultShowPreviousPeriod
|
||||
ratioModeModel.value = props.defaultRatioMode
|
||||
showProjectEventsModel.value = props.defaultShowProjectEvents
|
||||
showChartEventsModel.value = props.defaultShowChartEvents
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!isControlsMenuOpen.value || !(event.target instanceof Node)) return
|
||||
if (controlsMenuTrigger.value?.contains(event.target)) return
|
||||
if (controlsMenuPanel.value?.contains(event.target)) return
|
||||
isControlsMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-popper--theme-analytics-controls-menu .v-popper__inner {
|
||||
overflow: visible !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-analytics-controls-menu .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-5"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-5"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showLegendTopFade"
|
||||
class="z-1 pointer-events-none absolute left-0 right-0 top-0 h-5 bg-gradient-to-b from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="legendContainer"
|
||||
class="flex max-h-[130px] flex-wrap items-center gap-y-1 overflow-y-auto px-3"
|
||||
@scroll="checkLegendScrollState"
|
||||
>
|
||||
<div
|
||||
v-for="legendEntry in legendEntries"
|
||||
:key="legendEntry.id"
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<button
|
||||
v-tooltip="legendEntry.projectName ?? ''"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-sm !outline-0 transition-all focus-within:!outline-0 focus:!outline-0 focus-visible:!outline-0"
|
||||
:class="[
|
||||
legendEntry.hidden ? 'text-secondary opacity-70' : 'text-primary',
|
||||
isLegendEntryToggleDisabled(legendEntry) && !isShiftKeyPressed
|
||||
? 'cursor-default'
|
||||
: 'cursor-pointer hover:brightness-125',
|
||||
]"
|
||||
:aria-pressed="!legendEntry.hidden"
|
||||
@mouseenter="emit('entry-hover', legendEntry.id)"
|
||||
@mouseleave="emit('entry-hover-clear', legendEntry.id)"
|
||||
@focus="emit('entry-hover', legendEntry.id)"
|
||||
@blur="emit('entry-hover-clear', legendEntry.id)"
|
||||
@click="emit('entry-click', $event, legendEntry.id)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
legendEntry.isPreviousPeriod
|
||||
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
|
||||
: 'size-2 rounded-full'
|
||||
"
|
||||
:style="
|
||||
legendEntry.isPreviousPeriod
|
||||
? { borderColor: legendEntry.color }
|
||||
: { backgroundColor: legendEntry.color }
|
||||
"
|
||||
/>
|
||||
<span
|
||||
:class="{
|
||||
'line-through': legendEntry.hidden,
|
||||
capitalize: shouldCapitalizeDatasetLabels,
|
||||
}"
|
||||
>
|
||||
{{ legendEntry.name }}
|
||||
</span>
|
||||
</button>
|
||||
<Dropdown
|
||||
v-if="showUnmonetizedInfo && legendEntry.id === 'breakdown:unmonetized'"
|
||||
theme="analytics-monetization-popover"
|
||||
:triggers="['hover', 'focus']"
|
||||
:popper-triggers="['hover', 'focus']"
|
||||
:delay="{ show: 0, hide: 250 }"
|
||||
placement="top"
|
||||
:aria-id="monetizationPopoverId"
|
||||
no-auto-focus
|
||||
>
|
||||
<InfoIcon
|
||||
class="-ml-1 mt-px inline-flex size-4 items-center justify-center rounded-full border-0 bg-transparent p-0 text-secondary transition-all hover:text-contrast focus-visible:text-contrast"
|
||||
:aria-label="formatMessage(analyticsChartMessages.viewMonetizedAnalyticsDetails)"
|
||||
/>
|
||||
<template #popper>
|
||||
<div
|
||||
role="dialog"
|
||||
:aria-label="formatMessage(analyticsChartMessages.monetizedAnalyticsDetails)"
|
||||
class="font-base w-[292px] rounded-xl border border-solid border-surface-5 bg-surface-3 p-3 text-sm leading-snug shadow-2xl"
|
||||
>
|
||||
{{ formatMessage(analyticsChartMessages.monetizedAnalyticsDetailsDescription) }}
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-5"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-5"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showLegendBottomFade"
|
||||
class="z-1 pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types'
|
||||
|
||||
const props = defineProps<{
|
||||
legendEntries: AnalyticsChartLegendEntry[]
|
||||
shouldCapitalizeDatasetLabels: boolean
|
||||
showUnmonetizedInfo: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-hover': [datasetId: string]
|
||||
'entry-hover-clear': [datasetId: string]
|
||||
'entry-click': [event: MouseEvent, datasetId: string]
|
||||
}>()
|
||||
|
||||
const monetizationPopoverId = useId()
|
||||
const legendContainer = ref<HTMLElement | null>(null)
|
||||
const isShiftKeyPressed = ref(false)
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
showTopFade: showLegendTopFade,
|
||||
showBottomFade: showLegendBottomFade,
|
||||
checkScrollState: checkLegendScrollState,
|
||||
forceCheck: forceCheckLegendScrollState,
|
||||
} = useScrollIndicator(legendContainer)
|
||||
|
||||
function updateShiftKeyState(event: KeyboardEvent) {
|
||||
isShiftKeyPressed.value = event.shiftKey
|
||||
}
|
||||
|
||||
function clearShiftKeyState() {
|
||||
isShiftKeyPressed.value = false
|
||||
}
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.legendEntries,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
forceCheckLegendScrollState()
|
||||
})
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', updateShiftKeyState)
|
||||
window.addEventListener('keyup', updateShiftKeyState)
|
||||
window.addEventListener('blur', clearShiftKeyState)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', updateShiftKeyState)
|
||||
window.removeEventListener('keyup', updateShiftKeyState)
|
||||
window.removeEventListener('blur', clearShiftKeyState)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-popper--theme-analytics-monetization-popover .v-popper__inner {
|
||||
overflow: visible !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-analytics-monetization-popover .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(analyticsChartMessages.renderLimitHeader, { count: tableProjectCount })"
|
||||
fade="warning"
|
||||
width="500px"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<p class="m-0 max-w-[32rem] text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.renderLimitDescription) }}
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="modal?.hide()">
|
||||
{{ formatMessage(analyticsChartMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button class="!shadow-none" @click="confirm">
|
||||
{{ formatMessage(analyticsChartMessages.showAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
|
||||
defineProps<{
|
||||
tableProjectCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div
|
||||
class="flex min-h-[84px] w-full flex-col items-start justify-between gap-3 rounded-t-2xl border-0 border-b border-solid border-surface-5 bg-surface-3 p-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="w-max text-xl font-semibold text-contrast">
|
||||
{{ graphTitle }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showTableSelectionSubheading"
|
||||
class="m-0 flex w-max flex-wrap items-center gap-2 text-sm text-secondary"
|
||||
>
|
||||
<span>{{ tableSelectionSubheading }}</span>
|
||||
|
||||
<button
|
||||
v-if="showGraphRenderLimitButton"
|
||||
type="button"
|
||||
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
|
||||
@click="emit('toggle-graph-render-limit', $event)"
|
||||
>
|
||||
{{ graphRenderLimitButtonLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showTopGraphDatasetsButton"
|
||||
type="button"
|
||||
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
|
||||
@click="emit('show-top-graph-datasets')"
|
||||
>
|
||||
{{ formatMessage(analyticsChartMessages.showTopEight) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex grow select-none flex-wrap-reverse items-center justify-end gap-2 gap-y-2">
|
||||
<AnalyticsChartControls
|
||||
v-model:ratio-mode="ratioMode"
|
||||
v-model:show-chart-events="showChartEvents"
|
||||
v-model:show-project-events="showProjectEvents"
|
||||
v-model:show-previous-period="showPreviousPeriod"
|
||||
:can-use-ratio-mode="canUseRatioMode"
|
||||
:can-show-previous-period="canShowPreviousPeriod"
|
||||
:has-chart-events="hasChartEvents"
|
||||
:has-project-events="hasProjectEvents"
|
||||
:small-toggles="smallToggles"
|
||||
:default-ratio-mode="DEFAULT_ANALYTICS_GRAPH_RATIO_MODE"
|
||||
:default-show-chart-events="DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY"
|
||||
:default-show-project-events="defaultShowProjectEvents"
|
||||
:default-show-previous-period="DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY"
|
||||
/>
|
||||
<Tabs
|
||||
:value="activeGraphViewMode"
|
||||
:tabs="viewModeTabs"
|
||||
@update:value="activeGraphViewMode = $event as AnalyticsGraphViewMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChartAreaIcon, ChartColumnBigIcon, ChartSplineIcon } from '@modrinth/assets'
|
||||
import { Tabs, type TabsTab, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
|
||||
DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsGraphViewMode } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import AnalyticsChartControls from './AnalyticsChartControls.vue'
|
||||
|
||||
const activeGraphViewMode = defineModel<AnalyticsGraphViewMode>('activeGraphViewMode', {
|
||||
required: true,
|
||||
})
|
||||
const ratioMode = defineModel<boolean>('ratioMode', { required: true })
|
||||
const showChartEvents = defineModel<boolean>('showChartEvents', { required: true })
|
||||
const showProjectEvents = defineModel<boolean>('showProjectEvents', { required: true })
|
||||
const showPreviousPeriod = defineModel<boolean>('showPreviousPeriod', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
graphTitle: string
|
||||
showTableSelectionSubheading: boolean
|
||||
tableSelectionSubheading: string
|
||||
showGraphRenderLimitButton: boolean
|
||||
graphRenderLimitButtonLabel: string
|
||||
showTopGraphDatasetsButton: boolean
|
||||
canUseRatioMode: boolean
|
||||
canShowPreviousPeriod: boolean
|
||||
hasChartEvents: boolean
|
||||
hasProjectEvents: boolean
|
||||
smallToggles: boolean
|
||||
defaultShowProjectEvents: boolean
|
||||
isMobileLayout: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
'toggle-graph-render-limit': [event: MouseEvent]
|
||||
'show-top-graph-datasets': []
|
||||
}>()
|
||||
|
||||
const viewModeTabs = computed<TabsTab[]>(() => [
|
||||
{
|
||||
value: 'line',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.lineView),
|
||||
icon: ChartSplineIcon,
|
||||
},
|
||||
{
|
||||
value: 'area',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.areaView),
|
||||
icon: ChartAreaIcon,
|
||||
},
|
||||
{
|
||||
value: 'bar',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.barView),
|
||||
icon: ChartColumnBigIcon,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardProject,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import { COMBINED_BREAKDOWN_DATASET_ID_PREFIX } from '../../breakdown.ts'
|
||||
import {
|
||||
ALL_PROJECTS_DATASET_ID,
|
||||
MONETIZATION_LEGEND_ENTRY_ORDER,
|
||||
PREVIOUS_PERIOD_BORDER_DASH,
|
||||
} from '../analytics-chart-constants.ts'
|
||||
import type { AnalyticsChartEvent } from '../analytics-chart-plot/AnalyticsChartEvents.vue'
|
||||
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types.ts'
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
type ChartDataset,
|
||||
decodeBreakdownDatasetValue,
|
||||
getChartDatasetTotal,
|
||||
getPreviousPeriodDatasetId,
|
||||
} from '../analytics-chart-utils.ts'
|
||||
|
||||
export function useAnalyticsChartLegend({
|
||||
selectableChartDatasets,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
shouldShowPreviousPeriod,
|
||||
isRatioMode,
|
||||
hiddenGraphDatasetIds,
|
||||
selectedBreakdowns,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjects,
|
||||
selectedProjectIdSet,
|
||||
selectedProjectEventIdSet,
|
||||
}: {
|
||||
selectableChartDatasets: ComputedRef<ChartDataset[]>
|
||||
allChartDatasets: ComputedRef<ChartDataset[]>
|
||||
previousChartDatasets: ComputedRef<ChartDataset[]>
|
||||
shouldShowPreviousPeriod: ComputedRef<boolean>
|
||||
isRatioMode: Ref<boolean>
|
||||
hiddenGraphDatasetIds: Ref<string[]>
|
||||
selectedBreakdowns: Ref<readonly AnalyticsBreakdownPreset[]>
|
||||
isGraphDatasetSelectionActive: Ref<boolean>
|
||||
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>
|
||||
selectedProjectIdSet: ComputedRef<Set<string>>
|
||||
selectedProjectEventIdSet: ComputedRef<Set<string>>
|
||||
}) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const hoveredLegendEntryId = ref<string | null>(null)
|
||||
const hiddenDatasetIds = computed(() => new Set(hiddenGraphDatasetIds.value))
|
||||
const previousChartDatasetByOriginalId = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of previousChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const currentLegendEntries = computed<AnalyticsChartLegendEntry[]>(() =>
|
||||
selectableChartDatasets.value
|
||||
.map((dataset) => ({
|
||||
id: dataset.projectId,
|
||||
name: dataset.label,
|
||||
projectName: dataset.projectName,
|
||||
color: dataset.borderColor,
|
||||
totalValue: getChartDatasetTotal(dataset),
|
||||
hidden: hiddenDatasetIds.value.has(dataset.projectId),
|
||||
}))
|
||||
.sort(compareLegendEntries),
|
||||
)
|
||||
const visibleProjectEventIdSet = computed(() => {
|
||||
if (!selectedBreakdowns.value.includes('project')) {
|
||||
return selectedProjectEventIdSet.value
|
||||
}
|
||||
|
||||
const visibleProjectIds = new Set<string>()
|
||||
const projectIdsWithLegendEntries = new Set<string>()
|
||||
|
||||
for (const legendEntry of currentLegendEntries.value) {
|
||||
const projectId = getLegendEntryProjectId(legendEntry)
|
||||
if (!projectId) {
|
||||
continue
|
||||
}
|
||||
|
||||
projectIdsWithLegendEntries.add(projectId)
|
||||
if (!legendEntry.hidden) {
|
||||
visibleProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
if (isGraphDatasetSelectionActive.value) {
|
||||
return visibleProjectIds
|
||||
}
|
||||
|
||||
if (projectIdsWithLegendEntries.size === 0) {
|
||||
return selectedProjectEventIdSet.value
|
||||
}
|
||||
|
||||
const eventProjectIds = new Set<string>()
|
||||
for (const projectId of selectedProjectEventIdSet.value) {
|
||||
if (!projectIdsWithLegendEntries.has(projectId) || visibleProjectIds.has(projectId)) {
|
||||
eventProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
return eventProjectIds
|
||||
})
|
||||
const legendEntries = computed<AnalyticsChartLegendEntry[]>(() => {
|
||||
if (!shouldShowPreviousPeriod.value) {
|
||||
return currentLegendEntries.value
|
||||
}
|
||||
|
||||
return currentLegendEntries.value.flatMap((entry) => {
|
||||
const previousDataset = previousChartDatasetByOriginalId.value.get(entry.id)
|
||||
const previousEntry: AnalyticsChartLegendEntry = {
|
||||
id: getPreviousPeriodDatasetId(entry.id),
|
||||
name: formatMessage(analyticsChartMessages.previousPeriodSuffix, { name: entry.name }),
|
||||
projectName: entry.projectName,
|
||||
color: entry.color,
|
||||
totalValue: previousDataset ? getChartDatasetTotal(previousDataset) : 0,
|
||||
hidden: hiddenDatasetIds.value.has(getPreviousPeriodDatasetId(entry.id)),
|
||||
isPreviousPeriod: true,
|
||||
}
|
||||
|
||||
return [entry, previousEntry]
|
||||
})
|
||||
})
|
||||
const hiddenCurrentLegendEntryIds = computed(() =>
|
||||
currentLegendEntries.value.filter((entry) => entry.hidden).map((entry) => entry.id),
|
||||
)
|
||||
const hiddenCurrentLegendEntryIdsKey = computed(() =>
|
||||
hiddenCurrentLegendEntryIds.value.join('\u0000'),
|
||||
)
|
||||
const chartDatasetById = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of selectableChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
|
||||
if (!shouldShowPreviousPeriod.value) {
|
||||
continue
|
||||
}
|
||||
|
||||
const previousDataset = previousChartDatasetByOriginalId.value.get(dataset.projectId)
|
||||
const previousData = Array.from(
|
||||
{ length: dataset.data.length },
|
||||
(_, index) => previousDataset?.data[index] ?? 0,
|
||||
)
|
||||
datasets.set(getPreviousPeriodDatasetId(dataset.projectId), {
|
||||
projectId: getPreviousPeriodDatasetId(dataset.projectId),
|
||||
label: formatMessage(analyticsChartMessages.previousPeriodSuffix, {
|
||||
name: dataset.label,
|
||||
}),
|
||||
projectName: dataset.projectName,
|
||||
data: previousData,
|
||||
borderColor: dataset.borderColor,
|
||||
backgroundColor: dataset.backgroundColor,
|
||||
borderDash: PREVIOUS_PERIOD_BORDER_DASH,
|
||||
})
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const hoverRatioSliceTotals = computed(() => {
|
||||
const sliceLength = selectableChartDatasets.value.reduce(
|
||||
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
|
||||
0,
|
||||
)
|
||||
const totals = new Array<number>(sliceLength).fill(0)
|
||||
|
||||
for (const legendEntry of legendEntries.value) {
|
||||
if (legendEntry.hidden) continue
|
||||
|
||||
const dataset = chartDatasetById.value.get(legendEntry.id)
|
||||
if (!dataset) continue
|
||||
|
||||
for (let i = 0; i < sliceLength; i++) {
|
||||
totals[i] += dataset.data[i] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return totals
|
||||
})
|
||||
const baseVisibleChartDatasets = computed(() =>
|
||||
legendEntries.value
|
||||
.filter((legendEntry) => !legendEntry.hidden)
|
||||
.map((legendEntry) => {
|
||||
const dataset = chartDatasetById.value.get(legendEntry.id)
|
||||
if (!dataset) return null
|
||||
|
||||
return {
|
||||
...dataset,
|
||||
borderColor: legendEntry.color,
|
||||
backgroundColor: legendEntry.color,
|
||||
}
|
||||
})
|
||||
.filter((dataset): dataset is ChartDataset => Boolean(dataset)),
|
||||
)
|
||||
const visibleChartDatasets = computed<ChartDataset[]>(() => {
|
||||
const datasets = baseVisibleChartDatasets.value
|
||||
if (!isRatioMode.value || datasets.length === 0) return datasets
|
||||
|
||||
const sliceLength = datasets.reduce(
|
||||
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
|
||||
0,
|
||||
)
|
||||
const totals = new Array<number>(sliceLength).fill(0)
|
||||
for (const dataset of datasets) {
|
||||
for (let i = 0; i < sliceLength; i++) {
|
||||
totals[i] += dataset.data[i] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return datasets.map((dataset) => ({
|
||||
...dataset,
|
||||
data: dataset.data.map((value, i) => (totals[i] === 0 ? 0 : (value / totals[i]) * 100)),
|
||||
}))
|
||||
})
|
||||
const visibleChartDatasetById = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of visibleChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const highlightedChartDatasetId = computed(() => {
|
||||
const datasetId = hoveredLegendEntryId.value
|
||||
if (!datasetId || !visibleChartDatasetById.value.has(datasetId)) return null
|
||||
return datasetId
|
||||
})
|
||||
|
||||
function compareLegendEntries(a: AnalyticsChartLegendEntry, b: AnalyticsChartLegendEntry) {
|
||||
if (selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization') {
|
||||
const aOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(a.id)
|
||||
const bOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(b.id)
|
||||
|
||||
if (aOrder !== undefined || bOrder !== undefined) {
|
||||
return (aOrder ?? Number.MAX_SAFE_INTEGER) - (bOrder ?? Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
}
|
||||
|
||||
return b.totalValue - a.totalValue || a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
|
||||
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
|
||||
}
|
||||
|
||||
function getLegendEntryProjectId(legendEntry: AnalyticsChartLegendEntry) {
|
||||
const projectBreakdownIndex = selectedBreakdowns.value.findIndex(
|
||||
(breakdown) => breakdown === 'project',
|
||||
)
|
||||
|
||||
if (projectBreakdownIndex === -1) {
|
||||
if (selectedProjects.value.length === 1 && legendEntry.id === ALL_PROJECTS_DATASET_ID) {
|
||||
return selectedProjects.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedBreakdowns.value.length === 1) {
|
||||
return selectedProjectIdSet.value.has(legendEntry.id) ? legendEntry.id : null
|
||||
}
|
||||
|
||||
if (!legendEntry.id.startsWith(COMBINED_BREAKDOWN_DATASET_ID_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const values = legendEntry.id
|
||||
.slice(COMBINED_BREAKDOWN_DATASET_ID_PREFIX.length)
|
||||
.split('+')
|
||||
.map(decodeBreakdownDatasetValue)
|
||||
const projectId = values[projectBreakdownIndex]
|
||||
return projectId && selectedProjectIdSet.value.has(projectId) ? projectId : null
|
||||
}
|
||||
|
||||
function hidePreviousPeriodEntriesForHiddenCurrentEntries() {
|
||||
if (hiddenCurrentLegendEntryIds.value.length === 0) return
|
||||
|
||||
const nextHiddenDatasetIds = new Set(hiddenGraphDatasetIds.value)
|
||||
for (const datasetId of hiddenCurrentLegendEntryIds.value) {
|
||||
nextHiddenDatasetIds.add(getPreviousPeriodDatasetId(datasetId))
|
||||
}
|
||||
|
||||
const nextHiddenDatasetIdList = Array.from(nextHiddenDatasetIds)
|
||||
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIdList)) {
|
||||
hiddenGraphDatasetIds.value = nextHiddenDatasetIdList
|
||||
}
|
||||
}
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
|
||||
function getLegendEntryTooltip(legendEntry: AnalyticsChartLegendEntry) {
|
||||
return legendEntry.projectName ?? ''
|
||||
}
|
||||
|
||||
function isUnmonetizedLegendEntry(legendEntry: AnalyticsChartLegendEntry) {
|
||||
return (
|
||||
selectedBreakdowns.value.length === 1 &&
|
||||
selectedBreakdowns.value[0] === 'monetization' &&
|
||||
legendEntry.id === 'breakdown:unmonetized'
|
||||
)
|
||||
}
|
||||
|
||||
function setHoveredLegendEntryId(datasetId: string) {
|
||||
hoveredLegendEntryId.value = datasetId
|
||||
}
|
||||
|
||||
function clearHoveredLegendEntryId(datasetId: string) {
|
||||
if (hoveredLegendEntryId.value === datasetId) {
|
||||
hoveredLegendEntryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegendHoverState() {
|
||||
hoveredLegendEntryId.value = null
|
||||
}
|
||||
|
||||
function toggleLegendEntryVisibility(datasetId: string) {
|
||||
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
|
||||
if (nextHiddenDatasetIds.has(datasetId)) {
|
||||
nextHiddenDatasetIds.delete(datasetId)
|
||||
} else {
|
||||
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
|
||||
if (visibleCount <= 1) return
|
||||
nextHiddenDatasetIds.add(datasetId)
|
||||
}
|
||||
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
|
||||
}
|
||||
|
||||
function soloLegendEntry(datasetId: string) {
|
||||
const currentLegendEntryIds = new Set(legendEntries.value.map((entry) => entry.id))
|
||||
const otherIds = legendEntries.value.map((entry) => entry.id).filter((id) => id !== datasetId)
|
||||
const isAlreadySolo =
|
||||
!hiddenDatasetIds.value.has(datasetId) &&
|
||||
otherIds.every((id) => hiddenDatasetIds.value.has(id))
|
||||
|
||||
if (isAlreadySolo) {
|
||||
hiddenGraphDatasetIds.value = hiddenGraphDatasetIds.value.filter(
|
||||
(hiddenDatasetId) => !currentLegendEntryIds.has(hiddenDatasetId),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
|
||||
for (const legendEntry of legendEntries.value) {
|
||||
if (legendEntry.id === datasetId) {
|
||||
nextHiddenDatasetIds.delete(legendEntry.id)
|
||||
} else {
|
||||
nextHiddenDatasetIds.add(legendEntry.id)
|
||||
}
|
||||
}
|
||||
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
|
||||
}
|
||||
|
||||
function onLegendEntryClick(event: MouseEvent, datasetId: string) {
|
||||
if (event.shiftKey) {
|
||||
soloLegendEntry(datasetId)
|
||||
clearLegendHoverState()
|
||||
return
|
||||
}
|
||||
toggleLegendEntryVisibility(datasetId)
|
||||
clearLegendHoverState()
|
||||
}
|
||||
|
||||
function onTooltipEntryClick(datasetId: string, shiftKey: boolean) {
|
||||
if (!chartDatasetById.value.has(datasetId)) return
|
||||
|
||||
if (shiftKey) {
|
||||
soloLegendEntry(datasetId)
|
||||
clearLegendHoverState()
|
||||
return
|
||||
}
|
||||
toggleLegendEntryVisibility(datasetId)
|
||||
clearLegendHoverState()
|
||||
}
|
||||
|
||||
watch(
|
||||
[shouldShowPreviousPeriod, hiddenCurrentLegendEntryIdsKey],
|
||||
([showPreviousPeriod]) => {
|
||||
if (!showPreviousPeriod) return
|
||||
hidePreviousPeriodEntriesForHiddenCurrentEntries()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[allChartDatasets, legendEntries],
|
||||
([datasets]) => {
|
||||
if (datasets.length === 0) return
|
||||
|
||||
const availableDatasetIds = new Set(legendEntries.value.map((entry) => entry.id))
|
||||
const nextHiddenDatasetIds = hiddenGraphDatasetIds.value.filter((datasetId) =>
|
||||
availableDatasetIds.has(datasetId),
|
||||
)
|
||||
if (
|
||||
legendEntries.value.length > 0 &&
|
||||
legendEntries.value.every((entry) => nextHiddenDatasetIds.includes(entry.id))
|
||||
) {
|
||||
const firstLegendEntry = legendEntries.value[0]
|
||||
if (firstLegendEntry) {
|
||||
const firstLegendEntryIndex = nextHiddenDatasetIds.indexOf(firstLegendEntry.id)
|
||||
if (firstLegendEntryIndex !== -1) {
|
||||
nextHiddenDatasetIds.splice(firstLegendEntryIndex, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIds)) {
|
||||
hiddenGraphDatasetIds.value = nextHiddenDatasetIds
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
hoveredLegendEntryId,
|
||||
hiddenDatasetIds,
|
||||
previousChartDatasetByOriginalId,
|
||||
currentLegendEntries,
|
||||
visibleProjectEventIdSet,
|
||||
legendEntries,
|
||||
hiddenCurrentLegendEntryIds,
|
||||
hiddenCurrentLegendEntryIdsKey,
|
||||
chartDatasetById,
|
||||
hoverRatioSliceTotals,
|
||||
baseVisibleChartDatasets,
|
||||
visibleChartDatasets,
|
||||
visibleChartDatasetById,
|
||||
highlightedChartDatasetId,
|
||||
isProjectChartEventVisibleForLegend,
|
||||
getLegendEntryProjectId,
|
||||
hidePreviousPeriodEntriesForHiddenCurrentEntries,
|
||||
isLegendEntryToggleDisabled,
|
||||
getLegendEntryTooltip,
|
||||
isUnmonetizedLegendEntry,
|
||||
setHoveredLegendEntryId,
|
||||
clearHoveredLegendEntryId,
|
||||
clearLegendHoverState,
|
||||
toggleLegendEntryVisibility,
|
||||
soloLegendEntry,
|
||||
onLegendEntryClick,
|
||||
onTooltipEntryClick,
|
||||
}
|
||||
}
|
||||
+1154
File diff suppressed because it is too large
Load Diff
+488
@@ -0,0 +1,488 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="visible"
|
||||
ref="tooltipElement"
|
||||
class="analytics-chart-tooltip absolute left-0 top-0 z-10 flex max-h-[356px] flex-col overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-3 py-2 text-sm shadow-lg"
|
||||
:class="pinned ? '' : 'pointer-events-none'"
|
||||
:style="positionStyle"
|
||||
@wheel.stop
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
class="mb-1.5 flex shrink-0 items-start justify-between gap-2 border-0 border-b border-solid border-surface-5 px-3 pb-1.5 font-medium text-contrast"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="min-w-0 truncate">
|
||||
{{ rangeLabel }}
|
||||
<span v-if="durationLabel" class="text-xs font-normal text-secondary">
|
||||
({{ durationLabel }})
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="previousRangeLabel" class="min-w-0 space-x-1 truncate text-xs text-primary">
|
||||
<span class="font-medium">{{ previousRangeLabel }}</span>
|
||||
<span class="font-normal text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.previousPeriodShort) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<PinIcon
|
||||
v-if="pinned"
|
||||
v-tooltip="formatMessage(analyticsChartMessages.tooltipPinned)"
|
||||
class="pointer-events-none size-4 shrink-0 font-normal text-contrast"
|
||||
:aria-label="formatMessage(analyticsChartMessages.pinned)"
|
||||
/>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEntriesTopFade"
|
||||
class="analytics-chart-tooltip-entries-fade-top pointer-events-none absolute left-0 right-0 z-10 -mt-1 h-6 bg-gradient-to-b from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="entriesElement"
|
||||
class="analytics-chart-tooltip-entries flex min-h-0 flex-col overflow-y-auto overscroll-contain px-3"
|
||||
@scroll="checkEntriesScrollState"
|
||||
@touchstart="onEntriesTouchStart"
|
||||
@touchmove="onEntriesTouchMove"
|
||||
@touchend="clearEntriesTouchScroll"
|
||||
@touchcancel="clearEntriesTouchScroll"
|
||||
>
|
||||
<div v-if="!ratioMode" class="flex shrink-0 items-center justify-between gap-4">
|
||||
<span class="font-medium text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.total) }}
|
||||
</span>
|
||||
<span class="font-semibold text-contrast">{{ formattedTotal }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.projectId"
|
||||
class="flex w-full min-w-0 items-center justify-between gap-4 text-primary"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-w-0 items-center gap-1.5 border-0 bg-transparent p-0 py-0.5 text-left focus-visible:!outline-none"
|
||||
:class="
|
||||
entry.toggleDisabled && !shiftKeyPressed
|
||||
? 'cursor-default'
|
||||
: entry.hidden
|
||||
? 'cursor-pointer text-secondary opacity-70'
|
||||
: 'cursor-pointer text-primary transition-all hover:brightness-125'
|
||||
"
|
||||
:aria-label="getEntryAriaLabel(entry)"
|
||||
@mouseenter="emit('entry-hover', entry.projectId)"
|
||||
@mouseleave="emit('entry-hover-clear', entry.projectId)"
|
||||
@focus="emit('entry-hover', entry.projectId)"
|
||||
@blur="emit('entry-hover-clear', entry.projectId)"
|
||||
@click="onEntryClick($event, entry)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
entry.isPreviousPeriod
|
||||
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
|
||||
: 'size-2 rounded-full'
|
||||
"
|
||||
class="shrink-0"
|
||||
:style="
|
||||
entry.isPreviousPeriod
|
||||
? { borderColor: entry.color }
|
||||
: { backgroundColor: entry.color }
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-tooltip="entry.projectName ?? ''"
|
||||
class="min-w-0 truncate"
|
||||
:class="{
|
||||
'line-through': entry.hidden,
|
||||
capitalize: capitalizeLabels,
|
||||
}"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
:class="[
|
||||
'shrink-0',
|
||||
entry.isPreviousPeriod ? 'font-medium text-secondary' : 'font-semibold',
|
||||
entry.hidden ? 'text-primary line-through opacity-70' : 'text-contrast',
|
||||
]"
|
||||
>
|
||||
{{ entry.formattedValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEntriesBottomFade"
|
||||
class="analytics-chart-tooltip-entries-fade-bottom pointer-events-none absolute left-0 right-0 z-10 -mb-1 h-6 bg-gradient-to-t from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PinIcon } from '@modrinth/assets'
|
||||
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
|
||||
export type AnalyticsChartTooltipEntry = {
|
||||
projectId: string
|
||||
name: string
|
||||
projectName?: string
|
||||
color: string
|
||||
formattedValue: string
|
||||
hidden: boolean
|
||||
toggleDisabled: boolean
|
||||
isPreviousPeriod?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
start: Date | null
|
||||
end: Date | null
|
||||
previousStart: Date | null
|
||||
previousEnd: Date | null
|
||||
chartStart: Date | null
|
||||
chartEnd: Date | null
|
||||
formattedTotal: string
|
||||
entries: AnalyticsChartTooltipEntry[]
|
||||
containerWidth: number
|
||||
containerHeight: number
|
||||
pinned: boolean
|
||||
ratioMode: boolean
|
||||
capitalizeLabels: boolean
|
||||
shiftKeyPressed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-click': [projectId: string, shiftKey: boolean]
|
||||
'entry-hover': [projectId: string]
|
||||
'entry-hover-clear': [projectId: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function onEntryClick(event: MouseEvent, entry: AnalyticsChartTooltipEntry) {
|
||||
if (entry.toggleDisabled && !event.shiftKey) return
|
||||
emit('entry-click', entry.projectId, event.shiftKey)
|
||||
}
|
||||
|
||||
function getEntryAriaLabel(entry: AnalyticsChartTooltipEntry) {
|
||||
return formatMessage(
|
||||
entry.hidden
|
||||
? analyticsChartMessages.showEntryInGraph
|
||||
: analyticsChartMessages.hideEntryInGraph,
|
||||
{ name: entry.name },
|
||||
)
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000
|
||||
const ONE_MINUTE_MS = 60 * 1000
|
||||
const DATE_LOCALE = 'en-US'
|
||||
|
||||
function formatRangeLabel(
|
||||
start: Date,
|
||||
end: Date,
|
||||
chartStart: Date | null,
|
||||
chartEnd: Date | null,
|
||||
): string {
|
||||
const includeTime = end.getTime() - start.getTime() < ONE_DAY_MS
|
||||
const yearsDiffer = start.getFullYear() !== end.getFullYear()
|
||||
const chartYearsDiffer =
|
||||
chartStart !== null && chartEnd !== null && chartStart.getFullYear() !== chartEnd.getFullYear()
|
||||
const rangeYearDiffersFromChart =
|
||||
chartStart !== null && start.getFullYear() !== chartStart.getFullYear()
|
||||
const showTrailingYear = !yearsDiffer && (chartYearsDiffer || rangeYearDiffersFromChart)
|
||||
const monthsDiffer = yearsDiffer || start.getMonth() !== end.getMonth()
|
||||
|
||||
const timeOptions: Intl.DateTimeFormatOptions = includeTime
|
||||
? { hour: 'numeric', minute: '2-digit', hour12: true }
|
||||
: {}
|
||||
|
||||
const startOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(yearsDiffer ? { year: 'numeric' } : {}),
|
||||
...timeOptions,
|
||||
}
|
||||
|
||||
if (includeTime) {
|
||||
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, timeOptions).format(end)
|
||||
const range = `${startLabel}–${endLabel}`
|
||||
|
||||
if (!showTrailingYear) return range
|
||||
|
||||
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
|
||||
return `${range}, ${yearLabel}`
|
||||
}
|
||||
|
||||
let endOptions: Intl.DateTimeFormatOptions
|
||||
if (yearsDiffer) {
|
||||
endOptions = { month: 'short', day: 'numeric', year: 'numeric' }
|
||||
} else if (monthsDiffer) {
|
||||
endOptions = { month: 'short', day: 'numeric' }
|
||||
} else {
|
||||
endOptions = { day: 'numeric' }
|
||||
}
|
||||
|
||||
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, endOptions).format(end)
|
||||
const range = `${startLabel}–${endLabel}`
|
||||
|
||||
if (!showTrailingYear) return range
|
||||
|
||||
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
|
||||
return `${range}, ${yearLabel}`
|
||||
}
|
||||
|
||||
function formatDurationLabel(start: Date, end: Date): string {
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) return ''
|
||||
|
||||
if (durationMs >= ONE_DAY_MS) {
|
||||
const days = Math.round(durationMs / ONE_DAY_MS)
|
||||
return formatMessage(analyticsChartMessages.durationDays, { count: days })
|
||||
}
|
||||
if (durationMs >= ONE_HOUR_MS) {
|
||||
const hours = Math.round(durationMs / ONE_HOUR_MS)
|
||||
return formatMessage(analyticsChartMessages.durationHours, { count: hours })
|
||||
}
|
||||
const minutes = Math.max(1, Math.round(durationMs / ONE_MINUTE_MS))
|
||||
return formatMessage(analyticsChartMessages.durationMinutes, { count: minutes })
|
||||
}
|
||||
|
||||
const rangeLabel = computed(() =>
|
||||
props.start && props.end
|
||||
? formatRangeLabel(props.start, props.end, props.chartStart, props.chartEnd)
|
||||
: '',
|
||||
)
|
||||
|
||||
const durationLabel = computed(() =>
|
||||
props.start && props.end ? formatDurationLabel(props.start, props.end) : '',
|
||||
)
|
||||
const previousRangeLabel = computed(() =>
|
||||
props.previousStart && props.previousEnd
|
||||
? formatRangeLabel(props.previousStart, props.previousEnd, props.chartStart, props.chartEnd)
|
||||
: '',
|
||||
)
|
||||
|
||||
const tooltipElement = ref<HTMLDivElement | null>(null)
|
||||
const entriesElement = ref<HTMLDivElement | null>(null)
|
||||
const {
|
||||
showTopFade: showEntriesTopFade,
|
||||
showBottomFade: showEntriesBottomFade,
|
||||
checkScrollState: checkEntriesScrollState,
|
||||
forceCheck: forceCheckEntriesScrollState,
|
||||
} = useScrollIndicator(entriesElement)
|
||||
const tooltipWidth = ref(0)
|
||||
const tooltipHeight = ref(0)
|
||||
const entriesTopOffset = ref(0)
|
||||
const entriesBottomOffset = ref(0)
|
||||
const tooltipOffsetParentLeft = ref(0)
|
||||
const viewportWidth = ref(0)
|
||||
let entriesTouchStartY = 0
|
||||
let entriesTouchStartScrollTop = 0
|
||||
|
||||
const CURSOR_OFFSET = 12
|
||||
const EDGE_PADDING = 8
|
||||
const TOOLTIP_MAX_WIDTH = 26 * 16
|
||||
const WHEEL_DELTA_LINE = 1
|
||||
const WHEEL_DELTA_PAGE = 2
|
||||
const WHEEL_LINE_HEIGHT = 16
|
||||
|
||||
function getTooltipFallbackWidth() {
|
||||
const availableWidth = (viewportWidth.value || props.containerWidth) - EDGE_PADDING * 2
|
||||
if (availableWidth <= 0) return TOOLTIP_MAX_WIDTH
|
||||
return Math.min(TOOLTIP_MAX_WIDTH, availableWidth)
|
||||
}
|
||||
|
||||
function updateTooltipMeasurements() {
|
||||
nextTick(() => {
|
||||
const element = tooltipElement.value
|
||||
if (!element) return
|
||||
|
||||
tooltipWidth.value = element.offsetWidth
|
||||
tooltipHeight.value = element.offsetHeight
|
||||
|
||||
const entries = entriesElement.value
|
||||
if (entries) {
|
||||
entriesTopOffset.value = entries.offsetTop
|
||||
entriesBottomOffset.value = Math.max(
|
||||
0,
|
||||
element.offsetHeight - entries.offsetTop - entries.offsetHeight,
|
||||
)
|
||||
}
|
||||
|
||||
const offsetParent =
|
||||
element.offsetParent instanceof HTMLElement ? element.offsetParent : element.parentElement
|
||||
tooltipOffsetParentLeft.value = offsetParent?.getBoundingClientRect().left ?? 0
|
||||
viewportWidth.value =
|
||||
document.documentElement.clientWidth || window.innerWidth || props.containerWidth
|
||||
forceCheckEntriesScrollState()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.visible,
|
||||
props.entries,
|
||||
rangeLabel.value,
|
||||
durationLabel.value,
|
||||
previousRangeLabel.value,
|
||||
props.pinned,
|
||||
props.containerWidth,
|
||||
props.containerHeight,
|
||||
],
|
||||
updateTooltipMeasurements,
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
updateTooltipMeasurements()
|
||||
window.addEventListener('resize', updateTooltipMeasurements)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateTooltipMeasurements)
|
||||
})
|
||||
|
||||
function getNormalizedWheelDeltaY(event: WheelEvent, element: HTMLElement) {
|
||||
if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * element.clientHeight
|
||||
if (event.deltaMode === WHEEL_DELTA_LINE) return event.deltaY * WHEEL_LINE_HEIGHT
|
||||
return event.deltaY
|
||||
}
|
||||
|
||||
function getMaxScrollTop(element: HTMLElement) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
}
|
||||
|
||||
function consumeWheel(event: WheelEvent): boolean {
|
||||
const element = entriesElement.value
|
||||
if (!props.visible || !element) return false
|
||||
|
||||
const maxScrollTop = getMaxScrollTop(element)
|
||||
if (maxScrollTop <= 0) return false
|
||||
|
||||
const deltaY = getNormalizedWheelDeltaY(event, element)
|
||||
if (deltaY === 0) return false
|
||||
|
||||
const scrollTop = element.scrollTop
|
||||
element.scrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop + deltaY))
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function onEntriesTouchStart(event: TouchEvent) {
|
||||
const element = entriesElement.value
|
||||
const touch = event.touches[0]
|
||||
if (!element || !touch) return
|
||||
|
||||
entriesTouchStartY = touch.clientY
|
||||
entriesTouchStartScrollTop = element.scrollTop
|
||||
}
|
||||
|
||||
function onEntriesTouchMove(event: TouchEvent) {
|
||||
const element = entriesElement.value
|
||||
const touch = event.touches[0]
|
||||
if (!props.visible || !element || !touch) return
|
||||
|
||||
const maxScrollTop = getMaxScrollTop(element)
|
||||
if (maxScrollTop <= 0) return
|
||||
|
||||
const nextScrollTop = Math.min(
|
||||
maxScrollTop,
|
||||
Math.max(0, entriesTouchStartScrollTop + entriesTouchStartY - touch.clientY),
|
||||
)
|
||||
element.scrollTop = nextScrollTop
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function clearEntriesTouchScroll() {
|
||||
entriesTouchStartY = 0
|
||||
entriesTouchStartScrollTop = 0
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
consumeWheel,
|
||||
})
|
||||
|
||||
const positionStyle = computed(() => {
|
||||
const tooltipMaxWidth = getTooltipFallbackWidth()
|
||||
const tooltipWidthForPosition = tooltipWidth.value || tooltipMaxWidth
|
||||
const desiredLeft = props.x + CURSOR_OFFSET
|
||||
const viewportRight = viewportWidth.value || tooltipOffsetParentLeft.value + props.containerWidth
|
||||
const desiredViewportRight = tooltipOffsetParentLeft.value + desiredLeft + tooltipWidthForPosition
|
||||
const shouldPlaceLeft =
|
||||
props.x <= props.containerWidth / 4 || desiredViewportRight > viewportRight - EDGE_PADDING
|
||||
const candidateLeft = shouldPlaceLeft
|
||||
? props.x - tooltipWidthForPosition - CURSOR_OFFSET
|
||||
: desiredLeft
|
||||
const minLeft = EDGE_PADDING - tooltipOffsetParentLeft.value
|
||||
const maxLeft = Math.max(
|
||||
minLeft,
|
||||
viewportRight - tooltipOffsetParentLeft.value - tooltipWidthForPosition - EDGE_PADDING,
|
||||
)
|
||||
const clampedLeft = Math.min(maxLeft, Math.max(minLeft, candidateLeft))
|
||||
|
||||
const desiredTop = props.y - tooltipHeight.value / 2
|
||||
const maxTop = Math.max(EDGE_PADDING, props.containerHeight - tooltipHeight.value - EDGE_PADDING)
|
||||
const clampedTop = Math.min(maxTop, Math.max(EDGE_PADDING, desiredTop))
|
||||
|
||||
return {
|
||||
'--analytics-chart-tooltip-max-width': `${tooltipMaxWidth}px`,
|
||||
'--analytics-chart-tooltip-entries-top': `${entriesTopOffset.value}px`,
|
||||
'--analytics-chart-tooltip-entries-bottom': `${entriesBottomOffset.value}px`,
|
||||
transform: `translate3d(${clampedLeft}px, ${clampedTop}px, 0)`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-chart-tooltip {
|
||||
min-width: min(14rem, var(--analytics-chart-tooltip-max-width, calc(100vw - 1rem)));
|
||||
max-width: var(--analytics-chart-tooltip-max-width, min(26rem, calc(100vw - 1rem)));
|
||||
transition: transform 750ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries-fade-top {
|
||||
top: var(--analytics-chart-tooltip-entries-top, 0rem);
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries-fade-bottom {
|
||||
bottom: var(--analytics-chart-tooltip-entries-bottom, 0rem);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.analytics-chart-tooltip {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div
|
||||
ref="chartContainer"
|
||||
class="relative -ml-4 h-[460px] select-none"
|
||||
@click="onChartClick"
|
||||
@wheel.capture="onChartWheel"
|
||||
>
|
||||
<div :class="['h-full']">
|
||||
<div v-if="showEmptyChartState" class="flex h-full items-center justify-center rounded-xl">
|
||||
<div v-if="!isDataLoading" class="relative bottom-6 text-base font-normal text-secondary">
|
||||
{{ emptyChartMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<ClientOnly>
|
||||
<AnalyticsChartClient
|
||||
:type="chartType"
|
||||
:fill="isArea"
|
||||
:stacked="isStacked"
|
||||
:ratio-mode="isRatioMode"
|
||||
:datasets="visibleChartDatasets"
|
||||
:labels="chartLabels"
|
||||
:x-axis-tick-limit="xAxisTickLimit"
|
||||
:active-stat="activeStat"
|
||||
:pinned-slice-index="pinnedSliceIndex"
|
||||
:highlighted-dataset-id="highlightedChartDatasetId"
|
||||
@hover="onChartHover"
|
||||
@geometry="onChartGeometry"
|
||||
@pinned-drag="onPinnedDrag"
|
||||
@range-select="onRangeSelect"
|
||||
@touch-drag="onTouchDragEnd"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<AnalyticsChartEvents
|
||||
v-if="hasVisibleTimelineEvents"
|
||||
:events="visibleTimelineEvents"
|
||||
:active-stat="activeStat"
|
||||
:group-by="selectedGroupBy"
|
||||
:chart-start="chartRangeBounds?.start ?? null"
|
||||
:chart-end="chartRangeBounds?.end ?? null"
|
||||
:geometry="chartGeometry"
|
||||
/>
|
||||
<div
|
||||
v-if="showHoverGuide"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-solid border-contrast opacity-25"
|
||||
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
|
||||
/>
|
||||
<div
|
||||
v-if="showPinnedGuide"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-dashed border-green opacity-75"
|
||||
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
|
||||
/>
|
||||
<AnalyticsChartTooltip
|
||||
ref="chartTooltip"
|
||||
:visible="hoverState.visible"
|
||||
:x="hoverState.x"
|
||||
:y="hoverState.y"
|
||||
:start="hoverBucketRange?.start ?? null"
|
||||
:end="hoverBucketRange?.end ?? null"
|
||||
:previous-start="previousHoverBucketRange?.start ?? null"
|
||||
:previous-end="previousHoverBucketRange?.end ?? null"
|
||||
:chart-start="chartRangeBounds?.start ?? null"
|
||||
:chart-end="chartRangeBounds?.end ?? null"
|
||||
:formatted-total="hoverFormattedTotal"
|
||||
:entries="hoverEntries"
|
||||
:container-width="containerSize.width"
|
||||
:container-height="containerSize.height"
|
||||
:pinned="isHoverPinned"
|
||||
:ratio-mode="isRatioMode"
|
||||
:capitalize-labels="shouldCapitalizeDatasetLabels"
|
||||
:shift-key-pressed="isShiftKeyPressed"
|
||||
@entry-click="(datasetId, shiftKey) => emit('entry-click', datasetId, shiftKey)"
|
||||
@entry-hover="emit('entry-hover', $event)"
|
||||
@entry-hover-clear="emit('entry-hover-clear', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { useFormatNumber, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type {
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGroupByPreset,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import type {
|
||||
AnalyticsChartLegendEntry,
|
||||
AnalyticsChartRangeBounds,
|
||||
} from '../analytics-chart-types.ts'
|
||||
import type { ChartDataset } from '../analytics-chart-utils.ts'
|
||||
import { formatMetricValue } from '../analytics-chart-utils.ts'
|
||||
import AnalyticsChartClient from '../AnalyticsChart.client.vue'
|
||||
import AnalyticsChartEvents, { type AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
|
||||
import AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
|
||||
import { useAnalyticsChartInteractions } from './use-analytics-chart-interactions.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
chartType: 'line' | 'bar'
|
||||
isArea: boolean
|
||||
isStacked: boolean
|
||||
isRatioMode: boolean
|
||||
isDataLoading: boolean
|
||||
showEmptyChartState: boolean
|
||||
emptyChartMessage: string
|
||||
visibleChartDatasets: ChartDataset[]
|
||||
chartLabels: string[]
|
||||
xAxisTickLimit?: number
|
||||
activeStat: AnalyticsDashboardStat
|
||||
highlightedChartDatasetId: string | null
|
||||
hasVisibleTimelineEvents: boolean
|
||||
visibleTimelineEvents: AnalyticsChartEvent[]
|
||||
selectedGroupBy: AnalyticsGroupByPreset
|
||||
chartRangeBounds: AnalyticsChartRangeBounds | null
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
|
||||
sliceCount: number
|
||||
shouldShowPreviousPeriod: boolean
|
||||
allChartDatasets: ChartDataset[]
|
||||
currentLegendEntries: AnalyticsChartLegendEntry[]
|
||||
legendEntries: AnalyticsChartLegendEntry[]
|
||||
chartDatasetById: Map<string, ChartDataset>
|
||||
hoverRatioSliceTotals: number[]
|
||||
shouldCapitalizeDatasetLabels: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'range-select': [start: Date, end: Date, groupBy: AnalyticsGroupByPreset]
|
||||
'entry-click': [datasetId: string, shiftKey: boolean]
|
||||
'entry-hover': [datasetId: string]
|
||||
'entry-hover-clear': [datasetId: string]
|
||||
}>()
|
||||
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
chartContainer,
|
||||
chartTooltip,
|
||||
chartGeometry,
|
||||
containerSize,
|
||||
hoverState,
|
||||
isHoverPinned,
|
||||
isShiftKeyPressed,
|
||||
onChartHover,
|
||||
onPinnedDrag,
|
||||
onTouchDragEnd,
|
||||
onChartGeometry,
|
||||
onRangeSelect,
|
||||
onChartClick,
|
||||
onChartWheel,
|
||||
pinnedSliceIndex,
|
||||
showHoverGuide,
|
||||
showPinnedGuide,
|
||||
hoverBucketRange,
|
||||
previousHoverBucketRange,
|
||||
} = useAnalyticsChartInteractions({
|
||||
isDataLoading: computed(() => props.isDataLoading),
|
||||
fetchRequest: computed(() => props.fetchRequest),
|
||||
sliceCount: computed(() => props.sliceCount),
|
||||
chartLabels: computed(() => props.chartLabels),
|
||||
allChartDatasets: computed(() => props.allChartDatasets),
|
||||
chartRangeBounds: computed(() => props.chartRangeBounds),
|
||||
shouldShowPreviousPeriod: computed(() => props.shouldShowPreviousPeriod),
|
||||
onRangeSelected: (start, end, groupBy) => emit('range-select', start, end, groupBy),
|
||||
})
|
||||
|
||||
function getTooltipTotalMetricValue(value: number): number {
|
||||
if (props.activeStat === 'revenue' && Math.abs(value) < 1) {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const hoverTotalValue = computed(() => {
|
||||
if (hoverState.sliceIndex === null) return 0
|
||||
const sliceIndex = hoverState.sliceIndex
|
||||
if (props.isRatioMode) return props.hoverRatioSliceTotals[sliceIndex] ?? 0
|
||||
|
||||
return props.currentLegendEntries.reduce((sum, legendEntry) => {
|
||||
if (legendEntry.hidden) return sum
|
||||
const dataset = props.chartDatasetById.get(legendEntry.id)
|
||||
return sum + getTooltipTotalMetricValue(dataset?.data[sliceIndex] ?? 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const hoverFormattedTotal = computed(() => {
|
||||
if (props.isRatioMode) {
|
||||
return hoverTotalValue.value > 0 ? '100%' : '0%'
|
||||
}
|
||||
return formatMetricValue(hoverTotalValue.value, props.activeStat, formatNumber, formatMessage)
|
||||
})
|
||||
|
||||
const hoverEntries = computed(() => {
|
||||
if (hoverState.sliceIndex === null) return []
|
||||
const sliceIndex = hoverState.sliceIndex
|
||||
const totalValue = hoverTotalValue.value
|
||||
|
||||
return props.legendEntries.map((legendEntry) => {
|
||||
const dataset = props.chartDatasetById.get(legendEntry.id)
|
||||
const value = dataset?.data[sliceIndex] ?? 0
|
||||
const ratioValue = legendEntry.hidden || totalValue === 0 ? 0 : (value / totalValue) * 100
|
||||
return {
|
||||
projectId: legendEntry.id,
|
||||
name: legendEntry.name,
|
||||
projectName: legendEntry.projectName,
|
||||
color: legendEntry.color,
|
||||
formattedValue: props.isRatioMode
|
||||
? `${ratioValue.toFixed(1)}%`
|
||||
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage),
|
||||
hidden: legendEntry.hidden,
|
||||
toggleDisabled: !legendEntry.hidden && isLegendEntryToggleDisabled(legendEntry),
|
||||
isPreviousPeriod: legendEntry.isPreviousPeriod,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
</script>
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient, useVIntl } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import type { AnalyticsDashboardContextValue } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsProjectEventMessages, type FormatMessage } from '../../analytics-messages.ts'
|
||||
import {
|
||||
PROJECT_EVENT_DATE_FORMATTER,
|
||||
PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS,
|
||||
VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET,
|
||||
type VisibleProjectStatusChangeEventStatus,
|
||||
} from '../analytics-chart-constants.ts'
|
||||
import type { AnalyticsChartRangeBounds } from '../analytics-chart-types.ts'
|
||||
import type { AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
|
||||
|
||||
const analyticsEventsQueryKey = ['analytics-events'] as const
|
||||
|
||||
export function useAnalyticsChartEvents(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
| 'activeStat'
|
||||
| 'showChartEvents'
|
||||
| 'showProjectEvents'
|
||||
| 'displayedProjectEvents'
|
||||
| 'hasCompletedAnalyticsLoading'
|
||||
>,
|
||||
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>,
|
||||
selectedProjectNameById: ComputedRef<Map<string, string>>,
|
||||
selectedProjectEventIdSet: ComputedRef<Set<string>>,
|
||||
visibleProjectEventIdSet: ComputedRef<Set<string>>,
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { data: analyticsEvents } = useQuery({
|
||||
queryKey: analyticsEventsQueryKey,
|
||||
queryFn: () => client.labrinth.analytics_v3.getEvents(),
|
||||
enabled: computed(() => context.hasCompletedAnalyticsLoading.value),
|
||||
placeholderData: [],
|
||||
refetchOnMount: 'always',
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
|
||||
const hasChartEvents = computed(() =>
|
||||
localAnalyticsChartEvents.value.some(isTimelineEventVisibleInCurrentGraph),
|
||||
)
|
||||
const visibleModrinthChartEvents = computed<AnalyticsChartEvent[]>(() =>
|
||||
context.showChartEvents.value
|
||||
? localAnalyticsChartEvents.value.map((event) => ({
|
||||
...event,
|
||||
markerIcon: 'info' as const,
|
||||
groupKey: 'modrinth',
|
||||
}))
|
||||
: [],
|
||||
)
|
||||
const localProjectChartEvents = computed<AnalyticsChartEvent[]>(() =>
|
||||
dedupeProjectVersionUploadEvents(
|
||||
context.displayedProjectEvents.value.filter(
|
||||
(event) =>
|
||||
selectedProjectEventIdSet.value.has(event.project_id) && shouldShowProjectEvent(event),
|
||||
),
|
||||
).map((event) => ({
|
||||
title: getProjectEventTitle(event, formatMessage),
|
||||
starts: event.timestamp,
|
||||
ends: event.timestamp,
|
||||
projectId: event.project_id,
|
||||
projectName: selectedProjectNameById.value.get(event.project_id),
|
||||
subtitle: formatProjectEventDate(event.timestamp),
|
||||
markerIcon: 'flag' as const,
|
||||
groupKey: 'project',
|
||||
})),
|
||||
)
|
||||
const hasProjectEvents = computed(() =>
|
||||
localProjectChartEvents.value.some(
|
||||
(event) =>
|
||||
isProjectChartEventVisibleForLegend(event) && isTimelineEventVisibleInCurrentGraph(event),
|
||||
),
|
||||
)
|
||||
const visibleProjectChartEvents = computed(() =>
|
||||
context.showProjectEvents.value
|
||||
? localProjectChartEvents.value.filter(isProjectChartEventVisibleForLegend)
|
||||
: [],
|
||||
)
|
||||
const visibleTimelineEvents = computed(() => [
|
||||
...visibleModrinthChartEvents.value,
|
||||
...visibleProjectChartEvents.value,
|
||||
])
|
||||
const hasVisibleTimelineEvents = computed(
|
||||
() => visibleModrinthChartEvents.value.length > 0 || visibleProjectChartEvents.value.length > 0,
|
||||
)
|
||||
|
||||
function isTimelineEventVisibleInCurrentGraph(event: AnalyticsChartEvent) {
|
||||
const rangeBounds = chartRangeBounds.value
|
||||
if (!rangeBounds) return false
|
||||
if (!doesTimelineEventMatchActiveStat(event)) return false
|
||||
|
||||
const eventStartMs = new Date(event.starts).getTime()
|
||||
const eventEndMs = new Date(event.ends).getTime()
|
||||
if (!Number.isFinite(eventStartMs) || !Number.isFinite(eventEndMs)) return false
|
||||
if (eventEndMs < eventStartMs) return false
|
||||
|
||||
return eventEndMs >= rangeBounds.start.getTime() && eventStartMs <= rangeBounds.end.getTime()
|
||||
}
|
||||
|
||||
function doesTimelineEventMatchActiveStat(event: AnalyticsChartEvent) {
|
||||
if (!event.for_metric_kind?.length) return true
|
||||
return event.for_metric_kind.some((metricKind) => metricKind === context.activeStat.value)
|
||||
}
|
||||
|
||||
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
|
||||
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
|
||||
}
|
||||
|
||||
return {
|
||||
localAnalyticsChartEvents,
|
||||
hasChartEvents,
|
||||
visibleModrinthChartEvents,
|
||||
localProjectChartEvents,
|
||||
hasProjectEvents,
|
||||
visibleProjectChartEvents,
|
||||
visibleTimelineEvents,
|
||||
hasVisibleTimelineEvents,
|
||||
isTimelineEventVisibleInCurrentGraph,
|
||||
isProjectChartEventVisibleForLegend,
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectEventTitle(
|
||||
event: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
formatMessage: FormatMessage,
|
||||
) {
|
||||
if (event.kind === 'version_uploaded') {
|
||||
const versionNumber = event.version_number.trim()
|
||||
return versionNumber
|
||||
? formatMessage(analyticsProjectEventMessages.versionReleased, { version: versionNumber })
|
||||
: formatMessage(analyticsProjectEventMessages.versionUploaded)
|
||||
}
|
||||
|
||||
if (isVisibleProjectStatusChangeEventStatus(event.status_to)) {
|
||||
return getProjectStatusEventTitle(event.status_to, formatMessage)
|
||||
}
|
||||
|
||||
return formatMessage(analyticsProjectEventMessages.projectStatusChanged)
|
||||
}
|
||||
|
||||
function getProjectStatusEventTitle(
|
||||
status: VisibleProjectStatusChangeEventStatus,
|
||||
formatMessage: FormatMessage,
|
||||
) {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return formatMessage(analyticsProjectEventMessages.projectApproved)
|
||||
case 'unlisted':
|
||||
return formatMessage(analyticsProjectEventMessages.projectUnlisted)
|
||||
case 'private':
|
||||
return formatMessage(analyticsProjectEventMessages.projectPrivate)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowProjectEvent(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
|
||||
if (event.kind !== 'status_changed') {
|
||||
return true
|
||||
}
|
||||
|
||||
return isVisibleProjectStatusChangeEventStatus(event.status_to)
|
||||
}
|
||||
|
||||
function isVisibleProjectStatusChangeEventStatus(
|
||||
status: Labrinth.Projects.v2.ProjectStatus,
|
||||
): status is VisibleProjectStatusChangeEventStatus {
|
||||
return VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET.has(status)
|
||||
}
|
||||
|
||||
function dedupeProjectVersionUploadEvents(events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]) {
|
||||
const keptEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
|
||||
const keptVersionUploadEventsByKey = new Map<
|
||||
string,
|
||||
Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
|
||||
>()
|
||||
|
||||
for (const event of events) {
|
||||
const key = getProjectVersionUploadDedupeKey(event)
|
||||
if (!key) {
|
||||
keptEvents.push(event)
|
||||
continue
|
||||
}
|
||||
|
||||
const matchingEvents = keptVersionUploadEventsByKey.get(key) ?? []
|
||||
if (
|
||||
matchingEvents.some((matchingEvent) =>
|
||||
areProjectEventsWithinDedupeWindow(event, matchingEvent),
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
keptEvents.push(event)
|
||||
matchingEvents.push(event)
|
||||
keptVersionUploadEventsByKey.set(key, matchingEvents)
|
||||
}
|
||||
|
||||
return keptEvents
|
||||
}
|
||||
|
||||
function getProjectVersionUploadDedupeKey(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
|
||||
if (event.kind !== 'version_uploaded') return null
|
||||
|
||||
const versionNumber = event.version_number.trim()
|
||||
if (versionNumber.length === 0) return null
|
||||
|
||||
return `${event.project_id}:${versionNumber}`
|
||||
}
|
||||
|
||||
function areProjectEventsWithinDedupeWindow(
|
||||
left: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
right: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
) {
|
||||
const leftTimestamp = new Date(left.timestamp).getTime()
|
||||
const rightTimestamp = new Date(right.timestamp).getTime()
|
||||
if (!Number.isFinite(leftTimestamp) || !Number.isFinite(rightTimestamp)) return false
|
||||
|
||||
return Math.abs(leftTimestamp - rightTimestamp) <= PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS
|
||||
}
|
||||
|
||||
function formatProjectEventDate(timestamp: string) {
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return timestamp
|
||||
return PROJECT_EVENT_DATE_FORMATTER.format(date)
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { computed, type ComputedRef, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
ensureMinimumTimeRange,
|
||||
getDefaultAnalyticsGroupByForDurationMinutes,
|
||||
} from '../../query-builder/timeframe.ts'
|
||||
import type {
|
||||
AnalyticsChartHoverState,
|
||||
AnalyticsChartRangeBounds,
|
||||
} from '../analytics-chart-types.ts'
|
||||
import type { ChartDataset } from '../analytics-chart-utils.ts'
|
||||
import { getSliceBucketRange } from '../analytics-chart-utils.ts'
|
||||
import type {
|
||||
AnalyticsChartGeometryPayload,
|
||||
AnalyticsChartRangeSelectPayload,
|
||||
} from '../AnalyticsChart.client.vue'
|
||||
import type AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
|
||||
|
||||
export function useAnalyticsChartInteractions({
|
||||
isDataLoading,
|
||||
fetchRequest,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
allChartDatasets,
|
||||
chartRangeBounds,
|
||||
shouldShowPreviousPeriod,
|
||||
onRangeSelected,
|
||||
}: {
|
||||
isDataLoading: ComputedRef<boolean>
|
||||
fetchRequest: ComputedRef<Labrinth.Analytics.v3.FetchRequest | null>
|
||||
sliceCount: ComputedRef<number>
|
||||
chartLabels: ComputedRef<string[]>
|
||||
allChartDatasets: ComputedRef<ChartDataset[]>
|
||||
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>
|
||||
shouldShowPreviousPeriod: ComputedRef<boolean>
|
||||
onRangeSelected: (start: Date, end: Date, groupBy: AnalyticsGroupByPreset) => void
|
||||
}) {
|
||||
const chartContainer = ref<HTMLElement | null>(null)
|
||||
const chartTooltip = ref<InstanceType<typeof AnalyticsChartTooltip> | null>(null)
|
||||
const chartGeometry = ref<AnalyticsChartGeometryPayload | null>(null)
|
||||
const containerSize = reactive({ width: 0, height: 0 })
|
||||
const hoverState = reactive<AnalyticsChartHoverState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
sliceIndex: null,
|
||||
})
|
||||
const isHoverPinned = ref(false)
|
||||
const ignoreNextChartClick = ref(false)
|
||||
const isShiftKeyPressed = ref(false)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let clearIgnoredChartClickTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function setHoverState(payload: AnalyticsChartHoverState) {
|
||||
hoverState.visible = payload.visible
|
||||
hoverState.x = payload.x
|
||||
hoverState.y = payload.y
|
||||
hoverState.sliceIndex = payload.sliceIndex
|
||||
}
|
||||
|
||||
function clearHoverState() {
|
||||
hoverState.visible = false
|
||||
hoverState.sliceIndex = null
|
||||
}
|
||||
|
||||
function unpinHoverState() {
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
}
|
||||
|
||||
function updateShiftKeyState(event: KeyboardEvent) {
|
||||
isShiftKeyPressed.value = event.shiftKey
|
||||
}
|
||||
|
||||
function clearShiftKeyState() {
|
||||
isShiftKeyPressed.value = false
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!isHoverPinned.value) return
|
||||
if (event.target instanceof Node && chartContainer.value?.contains(event.target)) return
|
||||
unpinHoverState()
|
||||
}
|
||||
|
||||
function onChartHover(payload: AnalyticsChartHoverState) {
|
||||
if (isDataLoading.value) return
|
||||
if (isHoverPinned.value) return
|
||||
setHoverState(payload)
|
||||
}
|
||||
|
||||
function ignoreUpcomingChartClick() {
|
||||
ignoreNextChartClick.value = true
|
||||
if (clearIgnoredChartClickTimeout) {
|
||||
clearTimeout(clearIgnoredChartClickTimeout)
|
||||
}
|
||||
clearIgnoredChartClickTimeout = setTimeout(() => {
|
||||
ignoreNextChartClick.value = false
|
||||
clearIgnoredChartClickTimeout = null
|
||||
}, 350)
|
||||
}
|
||||
|
||||
function onPinnedDrag(payload: AnalyticsChartHoverState) {
|
||||
if (isDataLoading.value || !isHoverPinned.value) return
|
||||
ignoreUpcomingChartClick()
|
||||
setHoverState(payload)
|
||||
}
|
||||
|
||||
function onTouchDragEnd() {
|
||||
ignoreUpcomingChartClick()
|
||||
}
|
||||
|
||||
function onChartGeometry(payload: AnalyticsChartGeometryPayload) {
|
||||
chartGeometry.value = payload
|
||||
}
|
||||
|
||||
function getDefaultGroupByForRange(start: Date, end: Date) {
|
||||
const ensuredRange = ensureMinimumTimeRange(start, end)
|
||||
const durationMinutes = Math.max(
|
||||
1,
|
||||
Math.floor((ensuredRange.end.getTime() - ensuredRange.start.getTime()) / 60000),
|
||||
)
|
||||
|
||||
return getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
|
||||
}
|
||||
|
||||
function onRangeSelect(payload: AnalyticsChartRangeSelectPayload) {
|
||||
if (isDataLoading.value) return
|
||||
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest) return
|
||||
|
||||
if (payload.startSliceIndex === payload.endSliceIndex) {
|
||||
ignoreUpcomingChartClick()
|
||||
return
|
||||
}
|
||||
|
||||
const startSliceIndex = Math.min(payload.startSliceIndex, payload.endSliceIndex)
|
||||
const endSliceIndex = Math.max(payload.startSliceIndex, payload.endSliceIndex)
|
||||
const startBucketRange = getSliceBucketRange(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
startSliceIndex,
|
||||
)
|
||||
const endBucketRange = getSliceBucketRange(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
endSliceIndex,
|
||||
)
|
||||
const start = startBucketRange.start
|
||||
const end = endBucketRange.end
|
||||
|
||||
if (
|
||||
!Number.isFinite(start.getTime()) ||
|
||||
!Number.isFinite(end.getTime()) ||
|
||||
end.getTime() <= start.getTime()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
ignoreUpcomingChartClick()
|
||||
unpinHoverState()
|
||||
onRangeSelected(start, end, getDefaultGroupByForRange(start, end))
|
||||
}
|
||||
|
||||
function onChartClick() {
|
||||
if (isDataLoading.value) return
|
||||
if (ignoreNextChartClick.value) {
|
||||
ignoreNextChartClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!hoverState.visible || hoverState.sliceIndex === null) {
|
||||
if (isHoverPinned.value) {
|
||||
unpinHoverState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isHoverPinned.value) {
|
||||
unpinHoverState()
|
||||
return
|
||||
}
|
||||
|
||||
isHoverPinned.value = true
|
||||
}
|
||||
|
||||
function onChartWheel(event: WheelEvent) {
|
||||
if (isAnalyticsEventTooltipTrigger(event.target)) return
|
||||
if (!hoverState.visible) return
|
||||
chartTooltip.value?.consumeWheel(event)
|
||||
}
|
||||
|
||||
function isAnalyticsEventTooltipTrigger(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Element && target.closest('[data-analytics-event-tooltip-trigger]') !== null
|
||||
)
|
||||
}
|
||||
|
||||
const pinnedSliceIndex = computed(() => (isHoverPinned.value ? hoverState.sliceIndex : null))
|
||||
const showHoverGuide = computed(
|
||||
() =>
|
||||
!isDataLoading.value &&
|
||||
!isHoverPinned.value &&
|
||||
hoverState.visible &&
|
||||
hoverState.sliceIndex !== null,
|
||||
)
|
||||
const showPinnedGuide = computed(
|
||||
() =>
|
||||
!isDataLoading.value &&
|
||||
isHoverPinned.value &&
|
||||
hoverState.visible &&
|
||||
hoverState.sliceIndex !== null,
|
||||
)
|
||||
const hoverBucketRange = computed(() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest || hoverState.sliceIndex === null) return null
|
||||
return getSliceBucketRange(nextFetchRequest.time_range, sliceCount.value, hoverState.sliceIndex)
|
||||
})
|
||||
const previousHoverBucketRange = computed(() => {
|
||||
if (!shouldShowPreviousPeriod.value) return null
|
||||
|
||||
const bucketRange = hoverBucketRange.value
|
||||
const rangeBounds = chartRangeBounds.value
|
||||
if (!bucketRange || !rangeBounds) return null
|
||||
|
||||
const periodMs = rangeBounds.end.getTime() - rangeBounds.start.getTime()
|
||||
if (!Number.isFinite(periodMs) || periodMs <= 0) return null
|
||||
|
||||
return {
|
||||
start: new Date(bucketRange.start.getTime() - periodMs),
|
||||
end: new Date(bucketRange.end.getTime() - periodMs),
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (chartContainer.value && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
containerSize.width = entry.contentRect.width
|
||||
containerSize.height = entry.contentRect.height
|
||||
})
|
||||
resizeObserver.observe(chartContainer.value)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', updateShiftKeyState)
|
||||
window.addEventListener('keyup', updateShiftKeyState)
|
||||
window.addEventListener('blur', clearShiftKeyState)
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
window.removeEventListener('keydown', updateShiftKeyState)
|
||||
window.removeEventListener('keyup', updateShiftKeyState)
|
||||
window.removeEventListener('blur', clearShiftKeyState)
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
if (clearIgnoredChartClickTimeout) {
|
||||
clearTimeout(clearIgnoredChartClickTimeout)
|
||||
clearIgnoredChartClickTimeout = null
|
||||
}
|
||||
})
|
||||
|
||||
watch([chartLabels, allChartDatasets], () => {
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
})
|
||||
|
||||
watch(isDataLoading, (loading) => {
|
||||
if (!loading) return
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
})
|
||||
|
||||
return {
|
||||
chartContainer,
|
||||
chartTooltip,
|
||||
chartGeometry,
|
||||
containerSize,
|
||||
hoverState,
|
||||
isHoverPinned,
|
||||
isShiftKeyPressed,
|
||||
setHoverState,
|
||||
clearHoverState,
|
||||
unpinHoverState,
|
||||
onChartHover,
|
||||
onPinnedDrag,
|
||||
onTouchDragEnd,
|
||||
onChartGeometry,
|
||||
onRangeSelect,
|
||||
onChartClick,
|
||||
onChartWheel,
|
||||
pinnedSliceIndex,
|
||||
showHoverGuide,
|
||||
showPinnedGuide,
|
||||
hoverBucketRange,
|
||||
previousHoverBucketRange,
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { computed, type ComputedRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
export function useAnalyticsChartLayout(showEmptyChartState: ComputedRef<boolean>) {
|
||||
const graphSection = ref<HTMLElement | null>(null)
|
||||
const rememberedGraphSectionHeight = ref(0)
|
||||
const graphSectionStyle = computed(() =>
|
||||
showEmptyChartState.value && rememberedGraphSectionHeight.value > 0
|
||||
? { height: `${rememberedGraphSectionHeight.value}px` }
|
||||
: undefined,
|
||||
)
|
||||
let graphSectionResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function rememberGraphSectionHeight() {
|
||||
if (!graphSection.value) return
|
||||
|
||||
const height = graphSection.value.getBoundingClientRect().height
|
||||
if (height > 0) {
|
||||
rememberedGraphSectionHeight.value = height
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (graphSection.value && typeof ResizeObserver !== 'undefined') {
|
||||
graphSectionResizeObserver = new ResizeObserver(() => {
|
||||
if (showEmptyChartState.value) return
|
||||
rememberGraphSectionHeight()
|
||||
})
|
||||
graphSectionResizeObserver.observe(graphSection.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
graphSectionResizeObserver?.disconnect()
|
||||
graphSectionResizeObserver = null
|
||||
})
|
||||
|
||||
watch(showEmptyChartState, (showEmpty) => {
|
||||
if (showEmpty) {
|
||||
rememberGraphSectionHeight()
|
||||
} else {
|
||||
nextTick(rememberGraphSectionHeight)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
graphSection,
|
||||
graphSectionStyle,
|
||||
rememberGraphSectionHeight,
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
export type AnalyticsChartRangeBounds = {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
export type AnalyticsChartHoverState = {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
sliceIndex: number | null
|
||||
}
|
||||
|
||||
export type AnalyticsChartLegendEntry = {
|
||||
id: string
|
||||
name: string
|
||||
projectName?: string
|
||||
color: string
|
||||
totalValue: number
|
||||
hidden: boolean
|
||||
isPreviousPeriod?: boolean
|
||||
}
|
||||
+869
@@ -0,0 +1,869 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardProject,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGroupByPreset,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsChartMessages,
|
||||
analyticsMessages,
|
||||
analyticsStatCardMessages,
|
||||
formatAnalyticsDownloadReasonLabel,
|
||||
formatAnalyticsLoaderLabel,
|
||||
formatAnalyticsMonetizationLabel,
|
||||
type FormatMessage,
|
||||
} from '../analytics-messages'
|
||||
import {
|
||||
ALL_BREAKDOWN_VALUE,
|
||||
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
|
||||
getAnalyticsBreakdownDatasetId,
|
||||
getAnalyticsBreakdownKey,
|
||||
getAnalyticsBreakdownValues,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
} from '../breakdown'
|
||||
import { PREVIOUS_PERIOD_DATASET_ID_PREFIX } from './analytics-chart-constants'
|
||||
|
||||
export type ChartDataset = {
|
||||
projectId: string
|
||||
label: string
|
||||
projectName?: string
|
||||
data: number[]
|
||||
borderColor: string
|
||||
backgroundColor: string
|
||||
borderDash?: number[]
|
||||
}
|
||||
|
||||
export function getChartDatasetTotal(dataset: ChartDataset) {
|
||||
return dataset.data.reduce((sum, value) => sum + value, 0)
|
||||
}
|
||||
|
||||
export function getPreviousPeriodDatasetId(datasetId: string) {
|
||||
return `${PREVIOUS_PERIOD_DATASET_ID_PREFIX}${datasetId}`
|
||||
}
|
||||
|
||||
export function decodeBreakdownDatasetValue(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function areStringArraysEqual(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const LOADER_CHART_COLORS: Record<string, string> = {
|
||||
fabric: 'var(--color-platform-fabric)',
|
||||
'legacy-fabric': 'var(--color-platform-fabric)',
|
||||
quilt: 'var(--color-platform-quilt)',
|
||||
forge: 'var(--color-platform-forge)',
|
||||
neoforge: 'var(--color-platform-neoforge)',
|
||||
neo_forge: 'var(--color-platform-neoforge)',
|
||||
liteloader: 'var(--color-platform-liteloader)',
|
||||
bukkit: 'var(--color-platform-bukkit)',
|
||||
bungeecord: 'var(--color-platform-bungeecord)',
|
||||
folia: 'var(--color-platform-folia)',
|
||||
paper: 'var(--color-platform-paper)',
|
||||
purpur: 'var(--color-platform-purpur)',
|
||||
spigot: 'var(--color-platform-spigot)',
|
||||
velocity: 'var(--color-platform-velocity)',
|
||||
waterfall: 'var(--color-platform-waterfall)',
|
||||
sponge: 'var(--color-platform-sponge)',
|
||||
ornithe: 'var(--color-platform-ornithe)',
|
||||
'bta-babric': 'var(--color-platform-bta-babric)',
|
||||
nilloader: 'var(--color-platform-nilloader)',
|
||||
}
|
||||
|
||||
const REGION_CODE_PATTERN = /^[a-z]{2}$/i
|
||||
const OTHER_COUNTRY_CODE = 'XX'
|
||||
const ALL_PROJECTS_DATASET_ID = 'all'
|
||||
const MONETIZATION_CHART_COLOR_INDEX: Record<string, number> = {
|
||||
monetized: 0,
|
||||
unmonetized: 1,
|
||||
}
|
||||
const regionDisplayNamesByLocale = new Map<string, Intl.DisplayNames | null>()
|
||||
|
||||
function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
|
||||
if (regionDisplayNamesByLocale.has(locale)) {
|
||||
return regionDisplayNamesByLocale.get(locale) ?? null
|
||||
}
|
||||
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(locale, { type: 'region' })
|
||||
regionDisplayNamesByLocale.set(locale, displayNames)
|
||||
return displayNames
|
||||
} catch {
|
||||
regionDisplayNamesByLocale.set(locale, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountryCode(countryCode: string, formatMessage: FormatMessage): string {
|
||||
const normalized = countryCode.trim().toUpperCase()
|
||||
if (normalized === OTHER_COUNTRY_CODE) {
|
||||
return formatMessage(analyticsMessages.other)
|
||||
}
|
||||
|
||||
if (!REGION_CODE_PATTERN.test(normalized)) {
|
||||
return countryCode
|
||||
}
|
||||
|
||||
const locale = new Intl.DateTimeFormat().resolvedOptions().locale || 'en'
|
||||
const localizedDisplayNames = getRegionDisplayNames(locale)
|
||||
const localizedValue = localizedDisplayNames?.of(normalized)
|
||||
if (localizedValue && localizedValue !== normalized) {
|
||||
return localizedValue
|
||||
}
|
||||
|
||||
const englishDisplayNames = getRegionDisplayNames('en')
|
||||
const englishValue = englishDisplayNames?.of(normalized)
|
||||
if (englishValue && englishValue !== normalized) {
|
||||
return englishValue
|
||||
}
|
||||
|
||||
return countryCode
|
||||
}
|
||||
|
||||
export function formatBreakdownLabel(
|
||||
breakdownValue: string,
|
||||
selectedBreakdown: AnalyticsBreakdownPreset,
|
||||
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const normalizedValue = breakdownValue.trim()
|
||||
const normalizedLowercaseValue = normalizedValue.toLowerCase()
|
||||
|
||||
if (
|
||||
normalizedValue === UNKNOWN_BREAKDOWN_VALUE ||
|
||||
normalizedLowercaseValue === 'other' ||
|
||||
normalizedLowercaseValue === 'unknown'
|
||||
) {
|
||||
if (selectedBreakdown === 'country') {
|
||||
return formatMessage(analyticsMessages.other)
|
||||
}
|
||||
return formatMessage(analyticsMessages.unknown)
|
||||
}
|
||||
if (selectedBreakdown === 'country') {
|
||||
return formatCountryCode(breakdownValue, formatMessage)
|
||||
}
|
||||
if (selectedBreakdown === 'monetization') {
|
||||
return formatAnalyticsMonetizationLabel(normalizedLowercaseValue, formatMessage)
|
||||
}
|
||||
if (selectedBreakdown === 'download_reason') {
|
||||
return formatAnalyticsDownloadReasonLabel(normalizedLowercaseValue, formatMessage)
|
||||
}
|
||||
if (selectedBreakdown === 'version_id') {
|
||||
return getVersionDisplayName?.(breakdownValue) ?? breakdownValue
|
||||
}
|
||||
if (selectedBreakdown === 'loader') {
|
||||
return formatAnalyticsLoaderLabel(normalizedValue, formatMessage)
|
||||
}
|
||||
|
||||
return breakdownValue
|
||||
}
|
||||
|
||||
export function formatBreakdownLabels(
|
||||
breakdownValues: readonly string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return collapseRepeatedUnknownBreakdownLabels(
|
||||
selectedBreakdowns
|
||||
.filter((breakdown) => breakdown !== 'none')
|
||||
.map((breakdown, index) =>
|
||||
formatBreakdownLabel(
|
||||
breakdownValues[index] ?? '',
|
||||
breakdown,
|
||||
getVersionDisplayName,
|
||||
formatMessage,
|
||||
),
|
||||
),
|
||||
formatMessage,
|
||||
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
||||
}
|
||||
|
||||
function collapseRepeatedUnknownBreakdownLabels(
|
||||
labels: string[],
|
||||
formatMessage: FormatMessage,
|
||||
): string[] {
|
||||
let hasUnknownLabel = false
|
||||
const collapsedLabels: string[] = []
|
||||
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
|
||||
|
||||
for (const label of labels) {
|
||||
if (label === unknownBreakdownLabel) {
|
||||
if (hasUnknownLabel) {
|
||||
continue
|
||||
}
|
||||
hasUnknownLabel = true
|
||||
}
|
||||
|
||||
collapsedLabels.push(label)
|
||||
}
|
||||
|
||||
return collapsedLabels
|
||||
}
|
||||
|
||||
export function shouldCapitalizeBreakdownLabel(
|
||||
selectedBreakdown: AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[],
|
||||
): boolean {
|
||||
const selectedBreakdowns = Array.isArray(selectedBreakdown)
|
||||
? selectedBreakdown
|
||||
: [selectedBreakdown]
|
||||
return (
|
||||
selectedBreakdowns.length > 0 &&
|
||||
selectedBreakdowns.every(
|
||||
(breakdown) =>
|
||||
breakdown === 'download_reason' ||
|
||||
breakdown === 'monetization' ||
|
||||
breakdown === 'loader' ||
|
||||
breakdown === 'country',
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getBreakdownColor(
|
||||
breakdownValue: string,
|
||||
selectedBreakdown: AnalyticsBreakdownPreset,
|
||||
fallbackColor: string,
|
||||
palette: string[],
|
||||
): string {
|
||||
if (selectedBreakdown === 'monetization') {
|
||||
const colorIndex = MONETIZATION_CHART_COLOR_INDEX[breakdownValue]
|
||||
if (colorIndex !== undefined) {
|
||||
return getPaletteColorForIndex(colorIndex, palette)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedBreakdown !== 'loader') {
|
||||
return fallbackColor
|
||||
}
|
||||
|
||||
const normalizedLoader = breakdownValue.trim().toLowerCase()
|
||||
return LOADER_CHART_COLORS[normalizedLoader] ?? fallbackColor
|
||||
}
|
||||
|
||||
type PaletteRankEntry = {
|
||||
key: string
|
||||
label: string
|
||||
total: number
|
||||
}
|
||||
|
||||
function getPaletteColorForIndex(index: number, palette: string[]): string {
|
||||
if (palette.length === 0) return ''
|
||||
|
||||
return palette[index % palette.length]
|
||||
}
|
||||
|
||||
function buildPaletteColorsByDownloadRank(
|
||||
entries: PaletteRankEntry[],
|
||||
palette: string[],
|
||||
): Map<string, string> {
|
||||
const colorsByKey = new Map<string, string>()
|
||||
if (palette.length === 0) return colorsByKey
|
||||
|
||||
const sortedEntries = [...entries].sort(
|
||||
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
|
||||
)
|
||||
sortedEntries.forEach((entry, index) => {
|
||||
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
|
||||
})
|
||||
|
||||
return colorsByKey
|
||||
}
|
||||
|
||||
export function getMetricValue(
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
): number {
|
||||
switch (activeStat) {
|
||||
case 'views':
|
||||
return point.metric_kind === 'views' ? point.views : 0
|
||||
case 'downloads':
|
||||
return point.metric_kind === 'downloads' ? point.downloads : 0
|
||||
case 'playtime':
|
||||
return point.metric_kind === 'playtime' ? point.seconds : 0
|
||||
case 'revenue': {
|
||||
if (point.metric_kind !== 'revenue') return 0
|
||||
const value = Number.parseFloat(point.revenue)
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isMetricKindForStat(
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
): boolean {
|
||||
return point.metric_kind === activeStat
|
||||
}
|
||||
|
||||
function isProjectAnalyticsPointInSelectedProjects(
|
||||
point: Labrinth.Analytics.v3.AnalyticsData,
|
||||
selectedProjectIds: Set<string>,
|
||||
): point is Labrinth.Analytics.v3.ProjectAnalytics {
|
||||
return 'source_project' in point && selectedProjectIds.has(point.source_project)
|
||||
}
|
||||
|
||||
export function buildChartDatasets(
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
|
||||
selectedProjects: AnalyticsDashboardProject[],
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
palette: string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
||||
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
|
||||
formatMessage: FormatMessage,
|
||||
sliceCount: number = timeSlices.length,
|
||||
): ChartDataset[] {
|
||||
const selectedProjectIds = new Set(selectedProjects.map((project) => project.id))
|
||||
if (selectedProjectIds.size === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const dataLength = Math.max(sliceCount, timeSlices.length)
|
||||
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
|
||||
const projectNamesById = new Map(selectedProjects.map((project) => [project.id, project.name]))
|
||||
|
||||
function formatChartBreakdownLabels(breakdownValues: readonly string[]): string {
|
||||
return collapseRepeatedUnknownBreakdownLabels(
|
||||
normalizedBreakdowns.map((breakdown, index) => {
|
||||
const breakdownValue = breakdownValues[index] ?? ''
|
||||
if (breakdown === 'project') {
|
||||
return projectNamesById.get(breakdownValue) ?? breakdownValue
|
||||
}
|
||||
|
||||
return formatBreakdownLabel(breakdownValue, breakdown, getVersionDisplayName, formatMessage)
|
||||
}),
|
||||
formatMessage,
|
||||
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedBreakdowns.length > 0 &&
|
||||
!(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project')
|
||||
) {
|
||||
const dataByBreakdown = new Map<string, number[]>()
|
||||
const breakdownValuesByKey = new Map<string, string[]>()
|
||||
const downloadTotalsByBreakdown = new Map<string, number>()
|
||||
|
||||
timeSlices.forEach((slice, sliceIndex) => {
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
||||
|
||||
const breakdownValues = getAnalyticsBreakdownValues(
|
||||
point,
|
||||
normalizedBreakdowns,
|
||||
formatMessage,
|
||||
)
|
||||
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
||||
continue
|
||||
}
|
||||
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
||||
|
||||
if (!dataByBreakdown.has(breakdownKey)) {
|
||||
dataByBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
||||
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
||||
}
|
||||
|
||||
if (point.metric_kind === 'downloads') {
|
||||
downloadTotalsByBreakdown.set(
|
||||
breakdownKey,
|
||||
(downloadTotalsByBreakdown.get(breakdownKey) ?? 0) + getMetricValue(point, 'downloads'),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMetricKindForStat(point, activeStat)) continue
|
||||
|
||||
const breakdownData = dataByBreakdown.get(breakdownKey)
|
||||
if (!breakdownData) continue
|
||||
breakdownData[sliceIndex] += getMetricValue(point, activeStat)
|
||||
}
|
||||
})
|
||||
|
||||
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
|
||||
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
|
||||
key: breakdownKey,
|
||||
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
|
||||
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
|
||||
})),
|
||||
palette,
|
||||
)
|
||||
|
||||
return Array.from(dataByBreakdown.entries()).map(([breakdownKey, data]) => {
|
||||
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
|
||||
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
|
||||
const color =
|
||||
normalizedBreakdowns.length === 1
|
||||
? getBreakdownColor(
|
||||
breakdownValues[0] ?? '',
|
||||
normalizedBreakdowns[0],
|
||||
fallbackColor,
|
||||
palette,
|
||||
)
|
||||
: fallbackColor
|
||||
return {
|
||||
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
|
||||
label: formatChartBreakdownLabels(breakdownValues),
|
||||
projectName:
|
||||
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
|
||||
? getVersionProjectName?.(breakdownValues[0] ?? '')
|
||||
: undefined,
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedBreakdowns.length === 0) {
|
||||
const data = new Array(dataLength).fill(0)
|
||||
let downloadTotal = 0
|
||||
|
||||
timeSlices.forEach((slice, sliceIndex) => {
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
||||
|
||||
if (point.metric_kind === 'downloads') {
|
||||
downloadTotal += getMetricValue(point, 'downloads')
|
||||
}
|
||||
|
||||
if (!isMetricKindForStat(point, activeStat)) continue
|
||||
|
||||
data[sliceIndex] += getMetricValue(point, activeStat)
|
||||
}
|
||||
})
|
||||
|
||||
const color =
|
||||
buildPaletteColorsByDownloadRank(
|
||||
[
|
||||
{
|
||||
key: ALL_PROJECTS_DATASET_ID,
|
||||
label: formatMessage(analyticsMessages.allProjects),
|
||||
total: downloadTotal,
|
||||
},
|
||||
],
|
||||
palette,
|
||||
).get(ALL_PROJECTS_DATASET_ID) ?? ''
|
||||
const selectedProject = selectedProjects.length === 1 ? selectedProjects[0] : undefined
|
||||
|
||||
return [
|
||||
{
|
||||
projectId: ALL_PROJECTS_DATASET_ID,
|
||||
label: selectedProject?.name ?? formatMessage(analyticsMessages.allProjects),
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const dataByProjectBreakdown = new Map<string, number[]>()
|
||||
const breakdownValuesByKey = new Map<string, string[]>()
|
||||
const downloadTotalsByProjectBreakdown = new Map<string, number>()
|
||||
for (const project of selectedProjects) {
|
||||
const breakdownValues = [project.id]
|
||||
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
||||
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
||||
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
||||
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
|
||||
}
|
||||
|
||||
timeSlices.forEach((slice, sliceIndex) => {
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
||||
|
||||
const breakdownValues = getAnalyticsBreakdownValues(
|
||||
point,
|
||||
normalizedBreakdowns,
|
||||
formatMessage,
|
||||
)
|
||||
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
||||
continue
|
||||
}
|
||||
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
||||
if (!dataByProjectBreakdown.has(breakdownKey)) {
|
||||
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
||||
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
||||
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
|
||||
}
|
||||
|
||||
if (point.metric_kind === 'downloads') {
|
||||
downloadTotalsByProjectBreakdown.set(
|
||||
breakdownKey,
|
||||
(downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0) +
|
||||
getMetricValue(point, 'downloads'),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMetricKindForStat(point, activeStat)) continue
|
||||
|
||||
const projectData = dataByProjectBreakdown.get(breakdownKey)
|
||||
if (!projectData) continue
|
||||
|
||||
projectData[sliceIndex] += getMetricValue(point, activeStat)
|
||||
}
|
||||
})
|
||||
|
||||
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
|
||||
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
|
||||
key: breakdownKey,
|
||||
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
|
||||
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
|
||||
})),
|
||||
palette,
|
||||
)
|
||||
|
||||
return Array.from(dataByProjectBreakdown.entries()).map(([breakdownKey, data]) => {
|
||||
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
|
||||
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
|
||||
const color =
|
||||
normalizedBreakdowns.length === 1
|
||||
? getBreakdownColor(
|
||||
breakdownValues[0] ?? '',
|
||||
normalizedBreakdowns[0],
|
||||
fallbackColor,
|
||||
palette,
|
||||
)
|
||||
: fallbackColor
|
||||
return {
|
||||
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
|
||||
label: formatChartBreakdownLabels(breakdownValues),
|
||||
projectName:
|
||||
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
|
||||
? getVersionProjectName?.(breakdownValues[0] ?? '')
|
||||
: undefined,
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getSliceCount(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
fallback: number,
|
||||
): number {
|
||||
if ('slices' in timeRange.resolution) {
|
||||
return Math.max(1, timeRange.resolution.slices)
|
||||
}
|
||||
if ('minutes' in timeRange.resolution) {
|
||||
const duration = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
|
||||
const bucketMs = timeRange.resolution.minutes * 60 * 1000
|
||||
if (bucketMs > 0 && duration > 0) {
|
||||
return Math.max(1, Math.ceil(duration / bucketMs))
|
||||
}
|
||||
}
|
||||
return Math.max(1, fallback)
|
||||
}
|
||||
|
||||
export function getSliceBucketRange(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
sliceCount: number,
|
||||
index: number,
|
||||
): { start: Date; end: Date } {
|
||||
const startMs = new Date(timeRange.start).getTime()
|
||||
const endMs = new Date(timeRange.end).getTime()
|
||||
const bucketMs = sliceCount > 0 ? (endMs - startMs) / sliceCount : 0
|
||||
|
||||
return {
|
||||
start: new Date(startMs + index * bucketMs),
|
||||
end: new Date(startMs + (index + 1) * bucketMs),
|
||||
}
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const ONE_MINUTE_MS = 60 * 1000
|
||||
const YEAR_LABEL_TIME_RANGE_YEARS = 2
|
||||
const COMPACT_AXIS_THRESHOLD = 5
|
||||
const SHORT_HOURLY_TIME_LABEL_DURATION_MS = 6 * ONE_DAY_MS
|
||||
export const DEFAULT_X_AXIS_TICK_LIMIT = 12
|
||||
export const SHORT_HOURLY_AXIS_TICK_LIMIT = 8
|
||||
|
||||
export function buildTimeAxisLabels(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
sliceCount: number,
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
): string[] {
|
||||
const startMs = new Date(timeRange.start).getTime()
|
||||
const endMs = new Date(timeRange.end).getTime()
|
||||
const totalMs = endMs - startMs
|
||||
const bucketMs = sliceCount > 0 ? totalMs / sliceCount : 0
|
||||
const includeTime = shouldShowTimeForHourlyAxis(timeRange, groupBy)
|
||||
const includeYear = isYearRelevantForTimeRange(timeRange) || groupBy === 'year'
|
||||
|
||||
const dates: Date[] = []
|
||||
const dateKeys: string[] = []
|
||||
for (let i = 0; i < sliceCount; i++) {
|
||||
const date = new Date(startMs + (i + 1) * bucketMs)
|
||||
dates.push(date)
|
||||
dateKeys.push(`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`)
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
})
|
||||
|
||||
if (!includeTime) {
|
||||
return dates.map((date) => dateFormatter.format(date))
|
||||
}
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
|
||||
const uniqueDateCount = new Set(dateKeys).size
|
||||
|
||||
if (uniqueDateCount <= 1 || isSingleFullDayTimeRange(new Date(startMs), new Date(endMs))) {
|
||||
return dates.map((date) => timeFormatter.format(date))
|
||||
}
|
||||
|
||||
if (includeTime || sliceCount <= COMPACT_AXIS_THRESHOLD) {
|
||||
const dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
})
|
||||
return dates.map((date) => dateAndTimeFormatter.format(date))
|
||||
}
|
||||
|
||||
return dates.map((date) => dateFormatter.format(date))
|
||||
}
|
||||
|
||||
export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
|
||||
return groupBy === '1h' || groupBy === '6h'
|
||||
}
|
||||
|
||||
export function shouldUseShortHourlyAxis(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
): boolean {
|
||||
if (!isTimeRelevantForGroupBy(groupBy)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const durationMs = getTimeRangeDurationMs(timeRange)
|
||||
|
||||
return (
|
||||
Number.isFinite(durationMs) &&
|
||||
durationMs > 0 &&
|
||||
durationMs <= DEFAULT_X_AXIS_TICK_LIMIT * ONE_DAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
export function getShortHourlyAxisTickLimit(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
): number | undefined {
|
||||
if (!shouldUseShortHourlyAxis(timeRange, groupBy)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const durationMs = getTimeRangeDurationMs(timeRange)
|
||||
if (durationMs > SHORT_HOURLY_TIME_LABEL_DURATION_MS) {
|
||||
return Math.min(DEFAULT_X_AXIS_TICK_LIMIT, Math.ceil(durationMs / ONE_DAY_MS))
|
||||
}
|
||||
|
||||
return SHORT_HOURLY_AXIS_TICK_LIMIT
|
||||
}
|
||||
|
||||
function shouldShowTimeForHourlyAxis(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
): boolean {
|
||||
const durationMs = getTimeRangeDurationMs(timeRange)
|
||||
return (
|
||||
isTimeRelevantForGroupBy(groupBy) &&
|
||||
Number.isFinite(durationMs) &&
|
||||
durationMs > 0 &&
|
||||
durationMs <= SHORT_HOURLY_TIME_LABEL_DURATION_MS
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeRangeDurationMs(timeRange: Labrinth.Analytics.v3.TimeRange): number {
|
||||
return new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
|
||||
}
|
||||
|
||||
export function isYearRelevantForTimeRange(timeRange: Labrinth.Analytics.v3.TimeRange): boolean {
|
||||
const start = new Date(timeRange.start)
|
||||
const end = new Date(timeRange.end)
|
||||
const yearLabelThreshold = new Date(start)
|
||||
yearLabelThreshold.setFullYear(start.getFullYear() + YEAR_LABEL_TIME_RANGE_YEARS)
|
||||
|
||||
return (
|
||||
Number.isFinite(start.getTime()) &&
|
||||
Number.isFinite(end.getTime()) &&
|
||||
end.getTime() > yearLabelThreshold.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
export function formatBucketEndLabel(end: Date, includeTime: boolean, includeYear = false): string {
|
||||
if (includeTime) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(end)
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
}).format(end)
|
||||
}
|
||||
|
||||
function isStartOfDay(date: Date): boolean {
|
||||
return (
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0 &&
|
||||
date.getMilliseconds() === 0
|
||||
)
|
||||
}
|
||||
|
||||
function isSingleFullDayTimeRange(start: Date, end: Date): boolean {
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
return (
|
||||
Math.abs(durationMs - ONE_DAY_MS) < ONE_MINUTE_MS && isStartOfDay(start) && isStartOfDay(end)
|
||||
)
|
||||
}
|
||||
|
||||
export function formatMetricValue(
|
||||
value: number,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
formatNumber: (value: number) => string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (activeStat) {
|
||||
case 'revenue': {
|
||||
const amount = Math.round(value * 100) / 100
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatNumber(amount),
|
||||
})
|
||||
}
|
||||
case 'playtime': {
|
||||
const hours = value / 3600
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: Math.abs(hours) < 1 ? hours.toFixed(2) : hours.toFixed(1),
|
||||
})
|
||||
}
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
default:
|
||||
return formatNumber(Math.round(value))
|
||||
}
|
||||
}
|
||||
|
||||
function formatSmallAxisNumber(value: number): string {
|
||||
const rounded = Math.round(value)
|
||||
if (Math.abs(value - rounded) < 0.0000001) {
|
||||
return String(rounded)
|
||||
}
|
||||
|
||||
const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1)
|
||||
return trimTrailingFractionZeros(formattedValue)
|
||||
}
|
||||
|
||||
function trimTrailingFractionZeros(value: string): string {
|
||||
return value.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
|
||||
}
|
||||
|
||||
const COMPACT_AXIS_UNITS = [
|
||||
{ threshold: 1_000_000, divisor: 1_000_000, suffix: 'M' },
|
||||
{ threshold: 1_000, divisor: 1_000, suffix: 'K' },
|
||||
] as const
|
||||
const MAX_COMPACT_AXIS_DIGITS = 3
|
||||
|
||||
function getCompactAxisUnit(values: readonly number[]) {
|
||||
let maxAbsoluteValue = 0
|
||||
for (const value of values) {
|
||||
if (Number.isFinite(value)) {
|
||||
maxAbsoluteValue = Math.max(maxAbsoluteValue, Math.abs(value))
|
||||
}
|
||||
}
|
||||
|
||||
return COMPACT_AXIS_UNITS.find((unit) => maxAbsoluteValue >= unit.threshold) ?? null
|
||||
}
|
||||
|
||||
function formatCompactAxisNumber(value: number, axisValues: readonly number[]): string | null {
|
||||
if (Math.abs(value) === 0) return '0'
|
||||
|
||||
const unit = getCompactAxisUnit(axisValues)
|
||||
if (!unit) return null
|
||||
|
||||
return `${formatCompactAxisValue(value / unit.divisor)}${unit.suffix}`
|
||||
}
|
||||
|
||||
function formatCompactAxisValue(value: number): string {
|
||||
const absoluteValue = Math.abs(value)
|
||||
if (absoluteValue === 0) return '0'
|
||||
|
||||
const integerDigitCount = absoluteValue < 1 ? 1 : Math.floor(absoluteValue).toString().length
|
||||
const fractionDigitCount = Math.max(0, MAX_COMPACT_AXIS_DIGITS - integerDigitCount)
|
||||
const roundedValue = Number(value.toFixed(fractionDigitCount))
|
||||
const roundedIntegerDigitCount =
|
||||
Math.abs(roundedValue) < 1 ? 1 : Math.floor(Math.abs(roundedValue)).toString().length
|
||||
|
||||
if (roundedIntegerDigitCount > MAX_COMPACT_AXIS_DIGITS) {
|
||||
const truncatedValue = Math.sign(value) * (10 ** MAX_COMPACT_AXIS_DIGITS - 1)
|
||||
return String(truncatedValue)
|
||||
}
|
||||
|
||||
return trimTrailingFractionZeros(roundedValue.toFixed(fractionDigitCount))
|
||||
}
|
||||
|
||||
export function formatAxisValue(
|
||||
value: number,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
formatCompact: (value: number) => string,
|
||||
formatMessage: FormatMessage,
|
||||
axisValues: readonly number[] = [value],
|
||||
): string {
|
||||
switch (activeStat) {
|
||||
case 'revenue': {
|
||||
const amount = Math.round(value * 100) / 100
|
||||
const axisAmounts = axisValues.map((axisValue) => Math.round(axisValue * 100) / 100)
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatCompactAxisNumber(amount, axisAmounts) ?? formatCompact(amount),
|
||||
})
|
||||
}
|
||||
case 'playtime': {
|
||||
const formattedHours = formatCompactAxisNumber(value, axisValues)
|
||||
if (formattedHours) {
|
||||
return formatMessage(analyticsChartMessages.playtimeAxisHours, { hours: formattedHours })
|
||||
}
|
||||
if (Math.abs(value) < 10) {
|
||||
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
|
||||
hours: formatSmallAxisNumber(value),
|
||||
})
|
||||
}
|
||||
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
|
||||
hours: formatCompact(Math.round(value)),
|
||||
})
|
||||
}
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
default: {
|
||||
const roundedValue = Math.round(value)
|
||||
const roundedAxisValues = axisValues.map((axisValue) => Math.round(axisValue))
|
||||
const formattedValue = formatCompactAxisNumber(roundedValue, roundedAxisValues)
|
||||
if (formattedValue) return formattedValue
|
||||
if (Math.abs(value) < 10) {
|
||||
return formatSmallAxisNumber(value)
|
||||
}
|
||||
return formatCompact(roundedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<AnalyticsChartRenderLimitModal
|
||||
ref="showAllSelectedGraphDatasetsModal"
|
||||
:table-project-count="tableProjectCount"
|
||||
@confirm="confirmShowAllSelectedGraphDatasets"
|
||||
/>
|
||||
|
||||
<section
|
||||
ref="graphSection"
|
||||
class="relative flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3"
|
||||
:style="graphSectionStyle"
|
||||
>
|
||||
<AnalyticsChartHeader
|
||||
v-model:active-graph-view-mode="activeGraphViewMode"
|
||||
v-model:ratio-mode="isRatioMode"
|
||||
v-model:show-chart-events="showChartEvents"
|
||||
v-model:show-project-events="showProjectEvents"
|
||||
v-model:show-previous-period="showPreviousPeriod"
|
||||
:graph-title="graphTitle"
|
||||
:show-table-selection-subheading="showTableSelectionSubheading"
|
||||
:table-selection-subheading="tableSelectionSubheading"
|
||||
:show-graph-render-limit-button="showGraphRenderLimitButton"
|
||||
:graph-render-limit-button-label="graphRenderLimitButtonLabel"
|
||||
:show-top-graph-datasets-button="showTopGraphDatasetsButton"
|
||||
:can-use-ratio-mode="canUseRatioMode"
|
||||
:can-show-previous-period="canShowPreviousPeriodToggle"
|
||||
:has-chart-events="hasChartEvents"
|
||||
:has-project-events="hasProjectEvents"
|
||||
:small-toggles="!isMobileLayout"
|
||||
:default-show-project-events="defaultShowProjectEvents"
|
||||
:is-mobile-layout="isMobileLayout"
|
||||
@toggle-graph-render-limit="toggleGraphRenderLimit"
|
||||
@show-top-graph-datasets="showTopGraphDatasets"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-6 px-4 pb-6 pt-5"
|
||||
:class="['transition-opacity', isDataLoading ? 'pointer-events-none opacity-75' : '']"
|
||||
>
|
||||
<AnalyticsChartLegend
|
||||
:legend-entries="legendEntries"
|
||||
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
|
||||
:show-unmonetized-info="showUnmonetizedInfo"
|
||||
@entry-hover="setHoveredLegendEntryId"
|
||||
@entry-hover-clear="clearHoveredLegendEntryId"
|
||||
@entry-click="onLegendEntryClick"
|
||||
/>
|
||||
|
||||
<AnalyticsChartPlot
|
||||
:chart-type="chartType"
|
||||
:is-area="isArea"
|
||||
:is-stacked="isStacked"
|
||||
:is-ratio-mode="isRatioMode"
|
||||
:is-data-loading="isDataLoading"
|
||||
:show-empty-chart-state="showEmptyChartState"
|
||||
:empty-chart-message="emptyChartMessage"
|
||||
:visible-chart-datasets="visibleChartDatasets"
|
||||
:chart-labels="chartLabels"
|
||||
:x-axis-tick-limit="xAxisTickLimit"
|
||||
:active-stat="activeStat"
|
||||
:highlighted-chart-dataset-id="highlightedChartDatasetId"
|
||||
:has-visible-timeline-events="hasVisibleTimelineEvents"
|
||||
:visible-timeline-events="visibleTimelineEvents"
|
||||
:selected-group-by="selectedGroupBy"
|
||||
:chart-range-bounds="chartRangeBounds"
|
||||
:fetch-request="fetchRequest"
|
||||
:slice-count="sliceCount"
|
||||
:should-show-previous-period="shouldShowPreviousPeriod"
|
||||
:all-chart-datasets="allChartDatasets"
|
||||
:current-legend-entries="currentLegendEntries"
|
||||
:legend-entries="legendEntries"
|
||||
:chart-dataset-by-id="chartDatasetById"
|
||||
:hover-ratio-slice-totals="hoverRatioSliceTotals"
|
||||
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
|
||||
@range-select="onRangeSelect"
|
||||
@entry-click="onTooltipEntryClick"
|
||||
@entry-hover="setHoveredLegendEntryId"
|
||||
@entry-hover-clear="clearHoveredLegendEntryId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-[20] overflow-hidden rounded-xl">
|
||||
<AnalyticsLoadingBar :loading="isDataLoading" />
|
||||
</div>
|
||||
|
||||
<div v-if="isDataLoading" class="absolute inset-0 z-[19] overflow-hidden rounded-xl">
|
||||
<div class="absolute inset-0 bg-surface-3 opacity-50" />
|
||||
<div class="absolute inset-0 backdrop-blur-[3px]" />
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="relative bottom-6 inline-flex items-center gap-2 text-lg font-semibold text-primary"
|
||||
>
|
||||
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { getDefaultAnalyticsGraphProjectEventsVisibility } from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
import { injectAnalyticsDashboardContext } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsMessages } from '../analytics-messages.ts'
|
||||
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
|
||||
import AnalyticsChartLegend from './analytics-chart-header/AnalyticsChartLegend.vue'
|
||||
import AnalyticsChartRenderLimitModal from './analytics-chart-header/AnalyticsChartRenderLimitModal.vue'
|
||||
import AnalyticsChartHeader from './analytics-chart-header/index.vue'
|
||||
import { useAnalyticsChartLegend } from './analytics-chart-header/use-analytics-chart-legend.ts'
|
||||
import AnalyticsChartPlot from './analytics-chart-plot/index.vue'
|
||||
import { useAnalyticsChartEvents } from './analytics-chart-plot/use-analytics-chart-events.ts'
|
||||
import { useAnalyticsChartLayout } from './analytics-chart-plot/use-analytics-chart-layout.ts'
|
||||
import { useAnalyticsChartDatasets } from './use-analytics-chart-datasets.ts'
|
||||
import { useAnalyticsChartProjects } from './use-analytics-chart-projects.ts'
|
||||
|
||||
const dashboardContext = injectAnalyticsDashboardContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
activeStat,
|
||||
activeGraphViewMode,
|
||||
isRatioMode,
|
||||
showChartEvents,
|
||||
showProjectEvents,
|
||||
showPreviousPeriod,
|
||||
isMobileLayout,
|
||||
hiddenGraphDatasetIds,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjectIds: currentSelectedProjectIds,
|
||||
selectedTimeframeMode,
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy: selectedDashboardGroupBy,
|
||||
displayedFetchRequest: fetchRequest,
|
||||
displayedSelectedGroupBy: selectedGroupBy,
|
||||
displayedSelectedBreakdowns: selectedBreakdowns,
|
||||
isLoading,
|
||||
} = dashboardContext
|
||||
|
||||
const isDataLoading = computed(() => isLoading.value)
|
||||
const defaultShowProjectEvents = computed(() =>
|
||||
getDefaultAnalyticsGraphProjectEventsVisibility(currentSelectedProjectIds.value),
|
||||
)
|
||||
|
||||
const {
|
||||
selectedProjectIdSet,
|
||||
hasAvailableProjects,
|
||||
selectedProjects,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
} = useAnalyticsChartProjects(dashboardContext)
|
||||
const {
|
||||
showAllSelectedGraphDatasets,
|
||||
chartRangeBounds,
|
||||
tableProjectCount,
|
||||
showEmptyChartState,
|
||||
emptyChartMessage,
|
||||
graphTitle,
|
||||
showTableSelectionSubheading,
|
||||
shouldCapitalizeDatasetLabels,
|
||||
chartType,
|
||||
canShowPreviousPeriodToggle,
|
||||
shouldShowPreviousPeriod,
|
||||
isArea,
|
||||
isStacked,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
xAxisTickLimit,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
tableSelectionSubheading,
|
||||
showGraphRenderLimitButton,
|
||||
graphRenderLimitButtonLabel,
|
||||
showTopGraphDatasetsButton,
|
||||
selectableChartDatasets,
|
||||
showTopGraphDatasets,
|
||||
} = useAnalyticsChartDatasets(dashboardContext, selectedProjects, hasAvailableProjects)
|
||||
const {
|
||||
currentLegendEntries,
|
||||
visibleProjectEventIdSet,
|
||||
legendEntries,
|
||||
chartDatasetById,
|
||||
hoverRatioSliceTotals,
|
||||
visibleChartDatasets,
|
||||
highlightedChartDatasetId,
|
||||
setHoveredLegendEntryId,
|
||||
clearHoveredLegendEntryId,
|
||||
onLegendEntryClick,
|
||||
onTooltipEntryClick,
|
||||
} = useAnalyticsChartLegend({
|
||||
selectableChartDatasets,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
shouldShowPreviousPeriod,
|
||||
isRatioMode,
|
||||
hiddenGraphDatasetIds,
|
||||
selectedBreakdowns,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjects,
|
||||
selectedProjectIdSet,
|
||||
selectedProjectEventIdSet,
|
||||
})
|
||||
const { hasChartEvents, hasProjectEvents, visibleTimelineEvents, hasVisibleTimelineEvents } =
|
||||
useAnalyticsChartEvents(
|
||||
dashboardContext,
|
||||
chartRangeBounds,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
visibleProjectEventIdSet,
|
||||
)
|
||||
const { graphSection, graphSectionStyle } = useAnalyticsChartLayout(showEmptyChartState)
|
||||
|
||||
const showAllSelectedGraphDatasetsModal = ref<InstanceType<
|
||||
typeof AnalyticsChartRenderLimitModal
|
||||
> | null>(null)
|
||||
const canUseRatioMode = computed(
|
||||
() =>
|
||||
(activeGraphViewMode.value === 'area' || activeGraphViewMode.value === 'bar') &&
|
||||
legendEntries.value.length > 1,
|
||||
)
|
||||
const showUnmonetizedInfo = computed(
|
||||
() => selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization',
|
||||
)
|
||||
|
||||
function toggleGraphRenderLimit(event: MouseEvent) {
|
||||
if (showAllSelectedGraphDatasets.value) {
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
return
|
||||
}
|
||||
|
||||
showAllSelectedGraphDatasetsModal.value?.show(event)
|
||||
}
|
||||
|
||||
function confirmShowAllSelectedGraphDatasets() {
|
||||
showAllSelectedGraphDatasets.value = true
|
||||
}
|
||||
|
||||
function onRangeSelect(start: Date, end: Date, groupBy: AnalyticsGroupByPreset) {
|
||||
selectedTimeframeMode.value = 'custom_datetime_range'
|
||||
selectedCustomTimeframeStartDate.value = start.toISOString()
|
||||
selectedCustomTimeframeEndDate.value = end.toISOString()
|
||||
selectedDashboardGroupBy.value = groupBy
|
||||
}
|
||||
|
||||
watch(canUseRatioMode, (canUse) => {
|
||||
if (!canUse) {
|
||||
isRatioMode.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed, type ComputedRef, ref, watch } from 'vue'
|
||||
|
||||
import { useTheme } from '~/composables/nuxt-accessors'
|
||||
import { isDarkTheme } from '~/plugins/theme/index.ts'
|
||||
import type {
|
||||
AnalyticsDashboardContextValue,
|
||||
AnalyticsDashboardProject,
|
||||
AnalyticsDashboardStat,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsChartMessages,
|
||||
analyticsMessages,
|
||||
formatAnalyticsGraphTitle,
|
||||
type FormatMessage,
|
||||
getAnalyticsBreakdownItemType,
|
||||
} from '../analytics-messages'
|
||||
import {
|
||||
ANALYTICS_DASHBOARD_STATS,
|
||||
DARK_LEGEND_PALETTE,
|
||||
GRAPH_RENDER_DATASET_LIMIT,
|
||||
LIGHT_LEGEND_PALETTE,
|
||||
TOP_GRAPH_DATASET_LIMIT,
|
||||
} from './analytics-chart-constants'
|
||||
import {
|
||||
buildChartDatasets,
|
||||
buildTimeAxisLabels,
|
||||
type ChartDataset,
|
||||
getChartDatasetTotal,
|
||||
getShortHourlyAxisTickLimit,
|
||||
getSliceCount,
|
||||
shouldCapitalizeBreakdownLabel,
|
||||
} from './analytics-chart-utils'
|
||||
|
||||
export function useAnalyticsChartDatasets(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
| 'activeStat'
|
||||
| 'activeGraphViewMode'
|
||||
| 'isRatioMode'
|
||||
| 'showPreviousPeriod'
|
||||
| 'hasPreviousPeriodComparison'
|
||||
| 'hasProjectContext'
|
||||
| 'displayedFetchRequest'
|
||||
| 'displayedTimeSlices'
|
||||
| 'displayedPreviousTimeSlices'
|
||||
| 'displayedSelectedGroupBy'
|
||||
| 'displayedSelectedBreakdowns'
|
||||
| 'hiddenGraphDatasetIds'
|
||||
| 'hasExplicitGraphDatasetSelection'
|
||||
| 'isGraphDatasetSelectionActive'
|
||||
| 'selectedGraphDatasetIds'
|
||||
| 'defaultGraphDatasetIds'
|
||||
| 'topGraphDatasetIds'
|
||||
| 'getVersionDisplayName'
|
||||
| 'getVersionProjectName'
|
||||
>,
|
||||
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>,
|
||||
hasAvailableProjects: ComputedRef<boolean>,
|
||||
) {
|
||||
const theme = useTheme()
|
||||
const { formatMessage } = useVIntl()
|
||||
const showAllSelectedGraphDatasets = ref(false)
|
||||
|
||||
const chartRangeBounds = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (!nextFetchRequest) return null
|
||||
return {
|
||||
start: new Date(nextFetchRequest.time_range.start),
|
||||
end: new Date(nextFetchRequest.time_range.end),
|
||||
}
|
||||
})
|
||||
const showProjectVersionNames = computed(
|
||||
() =>
|
||||
context.displayedSelectedBreakdowns.value.includes('version_id') &&
|
||||
selectedProjects.value.length > 1,
|
||||
)
|
||||
const tableProjectCount = computed(() => context.selectedGraphDatasetIds.value.length)
|
||||
const isTableGraphSelectionEmpty = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
context.hasExplicitGraphDatasetSelection.value &&
|
||||
tableProjectCount.value === 0,
|
||||
)
|
||||
const showEmptyChartState = computed(
|
||||
() => selectedProjects.value.length === 0 || isTableGraphSelectionEmpty.value,
|
||||
)
|
||||
const emptyChartMessage = computed(() => {
|
||||
if (isTableGraphSelectionEmpty.value) {
|
||||
return formatMessage(analyticsChartMessages.selectTableItemsEmpty)
|
||||
}
|
||||
|
||||
if (context.hasProjectContext.value) {
|
||||
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
|
||||
}
|
||||
|
||||
return hasAvailableProjects.value
|
||||
? formatMessage(analyticsMessages.noDataAvailable)
|
||||
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
|
||||
})
|
||||
const legendPalette = computed(() =>
|
||||
isDarkTheme(theme.active) ? DARK_LEGEND_PALETTE : LIGHT_LEGEND_PALETTE,
|
||||
)
|
||||
const graphTitle = computed(() =>
|
||||
formatAnalyticsGraphTitle(context.activeStat.value, formatMessage),
|
||||
)
|
||||
const showTableSelectionSubheading = computed(
|
||||
() => context.isGraphDatasetSelectionActive.value && tableProjectCount.value > 0,
|
||||
)
|
||||
const tableBreakdownItemType = computed(() =>
|
||||
getAnalyticsBreakdownItemType(context.displayedSelectedBreakdowns.value),
|
||||
)
|
||||
const shouldCapitalizeDatasetLabels = computed(() =>
|
||||
shouldCapitalizeBreakdownLabel(context.displayedSelectedBreakdowns.value),
|
||||
)
|
||||
const chartType = computed<'line' | 'bar'>(() =>
|
||||
context.activeGraphViewMode.value === 'bar' ? 'bar' : 'line',
|
||||
)
|
||||
const canShowPreviousPeriodToggle = computed(
|
||||
() => context.activeGraphViewMode.value === 'line' && context.hasPreviousPeriodComparison.value,
|
||||
)
|
||||
const shouldShowPreviousPeriod = computed(
|
||||
() => canShowPreviousPeriodToggle.value && context.showPreviousPeriod.value,
|
||||
)
|
||||
const isArea = computed(() => context.activeGraphViewMode.value === 'area')
|
||||
const isStacked = computed(
|
||||
() =>
|
||||
context.isRatioMode.value ||
|
||||
context.activeGraphViewMode.value === 'area' ||
|
||||
context.activeGraphViewMode.value === 'bar',
|
||||
)
|
||||
const sliceCount = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
const fallback = context.displayedTimeSlices.value.length
|
||||
if (!nextFetchRequest) return Math.max(1, fallback)
|
||||
return getSliceCount(nextFetchRequest.time_range, fallback)
|
||||
})
|
||||
const chartLabels = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (!nextFetchRequest) return []
|
||||
return buildTimeAxisLabels(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
context.displayedSelectedGroupBy.value,
|
||||
)
|
||||
})
|
||||
const xAxisTickLimit = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
return nextFetchRequest
|
||||
? getShortHourlyAxisTickLimit(
|
||||
nextFetchRequest.time_range,
|
||||
context.displayedSelectedGroupBy.value,
|
||||
)
|
||||
: undefined
|
||||
})
|
||||
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
context.displayedSelectedBreakdowns.value,
|
||||
context.getVersionDisplayName,
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedPreviousTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
context.displayedSelectedBreakdowns.value,
|
||||
context.getVersionDisplayName,
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
const allChartDatasets = computed(() => chartDatasetsByStat.value[context.activeStat.value])
|
||||
const previousChartDatasets = computed(
|
||||
() => previousChartDatasetsByStat.value[context.activeStat.value],
|
||||
)
|
||||
const sortedChartDatasetIds = computed(() => sortDatasetsByTotal(allChartDatasets.value))
|
||||
const chartTopGraphDatasetIds = computed(() =>
|
||||
sortedChartDatasetIds.value.slice(0, TOP_GRAPH_DATASET_LIMIT),
|
||||
)
|
||||
const fallbackDefaultGraphDatasetIds = computed(() =>
|
||||
context.defaultGraphDatasetIds.value.length > 0
|
||||
? context.defaultGraphDatasetIds.value
|
||||
: chartTopGraphDatasetIds.value,
|
||||
)
|
||||
const isShowingAllTableItems = computed(() => {
|
||||
if (context.selectedGraphDatasetIds.value.length !== sortedChartDatasetIds.value.length) {
|
||||
return false
|
||||
}
|
||||
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
|
||||
return sortedChartDatasetIds.value.every((datasetId) => selectedDatasetIds.has(datasetId))
|
||||
})
|
||||
const isShowingTopGraphDatasets = computed(() => {
|
||||
if (
|
||||
context.selectedGraphDatasetIds.value.length !== fallbackDefaultGraphDatasetIds.value.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
|
||||
return fallbackDefaultGraphDatasetIds.value.every((datasetId) =>
|
||||
selectedDatasetIds.has(datasetId),
|
||||
)
|
||||
})
|
||||
const isShowingTopTableItems = computed(() => {
|
||||
const topDatasetIds = new Set(
|
||||
context.topGraphDatasetIds.value.slice(0, context.selectedGraphDatasetIds.value.length),
|
||||
)
|
||||
return context.selectedGraphDatasetIds.value.every((datasetId) => topDatasetIds.has(datasetId))
|
||||
})
|
||||
const isGraphRenderDatasetOverLimit = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
selectedChartDatasets.value.length > GRAPH_RENDER_DATASET_LIMIT,
|
||||
)
|
||||
const isGraphRenderDatasetLimitActive = computed(
|
||||
() => isGraphRenderDatasetOverLimit.value && !showAllSelectedGraphDatasets.value,
|
||||
)
|
||||
const tableSelectionSubheading = computed(() => {
|
||||
if (isGraphRenderDatasetLimitActive.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionLimited, {
|
||||
limit: GRAPH_RENDER_DATASET_LIMIT,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (isShowingAllTableItems.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionAll, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (isShowingTopTableItems.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionTop, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
return formatMessage(analyticsChartMessages.tableSelectionCount, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
})
|
||||
const shouldUseDefaultGraphDatasetSelection = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
!context.hasExplicitGraphDatasetSelection.value &&
|
||||
context.selectedGraphDatasetIds.value.length === 0,
|
||||
)
|
||||
const selectedGraphDatasetIdSet = computed(() => {
|
||||
if (shouldUseDefaultGraphDatasetSelection.value) {
|
||||
return new Set(fallbackDefaultGraphDatasetIds.value)
|
||||
}
|
||||
|
||||
return new Set(context.selectedGraphDatasetIds.value)
|
||||
})
|
||||
const selectedChartDatasets = computed(() => {
|
||||
if (!context.isGraphDatasetSelectionActive.value) {
|
||||
return allChartDatasets.value
|
||||
}
|
||||
|
||||
return allChartDatasets.value.filter((dataset) =>
|
||||
selectedGraphDatasetIdSet.value.has(dataset.projectId),
|
||||
)
|
||||
})
|
||||
const sortedSelectedChartDatasetIds = computed(() =>
|
||||
sortDatasetsByTotal(selectedChartDatasets.value),
|
||||
)
|
||||
const showGraphRenderLimitButton = computed(() => isGraphRenderDatasetOverLimit.value)
|
||||
const graphRenderLimitButtonLabel = computed(() =>
|
||||
showAllSelectedGraphDatasets.value
|
||||
? formatMessage(analyticsChartMessages.showLimited)
|
||||
: formatMessage(analyticsChartMessages.showAll),
|
||||
)
|
||||
const showTopGraphDatasetsButton = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
context.topGraphDatasetIds.value.length > 0 &&
|
||||
!isShowingTopGraphDatasets.value,
|
||||
)
|
||||
const limitedGraphDatasetIds = computed(
|
||||
() => new Set(sortedSelectedChartDatasetIds.value.slice(0, GRAPH_RENDER_DATASET_LIMIT)),
|
||||
)
|
||||
const selectableChartDatasets = computed(() => {
|
||||
if (!isGraphRenderDatasetLimitActive.value) {
|
||||
return selectedChartDatasets.value
|
||||
}
|
||||
|
||||
return selectedChartDatasets.value.filter((dataset) =>
|
||||
limitedGraphDatasetIds.value.has(dataset.projectId),
|
||||
)
|
||||
})
|
||||
|
||||
function showTopGraphDatasets() {
|
||||
context.selectedGraphDatasetIds.value = []
|
||||
context.hasExplicitGraphDatasetSelection.value = false
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
}
|
||||
|
||||
watch([() => context.selectedGraphDatasetIds.value.join('\u0000'), allChartDatasets], () => {
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
showAllSelectedGraphDatasets,
|
||||
chartRangeBounds,
|
||||
showProjectVersionNames,
|
||||
tableProjectCount,
|
||||
isTableGraphSelectionEmpty,
|
||||
showEmptyChartState,
|
||||
emptyChartMessage,
|
||||
legendPalette,
|
||||
graphTitle,
|
||||
showTableSelectionSubheading,
|
||||
shouldCapitalizeDatasetLabels,
|
||||
chartType,
|
||||
canShowPreviousPeriodToggle,
|
||||
shouldShowPreviousPeriod,
|
||||
isArea,
|
||||
isStacked,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
xAxisTickLimit,
|
||||
chartDatasetsByStat,
|
||||
previousChartDatasetsByStat,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
sortedChartDatasetIds,
|
||||
chartTopGraphDatasetIds,
|
||||
fallbackDefaultGraphDatasetIds,
|
||||
isShowingAllTableItems,
|
||||
isShowingTopGraphDatasets,
|
||||
isShowingTopTableItems,
|
||||
tableSelectionSubheading,
|
||||
shouldUseDefaultGraphDatasetSelection,
|
||||
selectedGraphDatasetIdSet,
|
||||
selectedChartDatasets,
|
||||
sortedSelectedChartDatasetIds,
|
||||
isGraphRenderDatasetOverLimit,
|
||||
showGraphRenderLimitButton,
|
||||
graphRenderLimitButtonLabel,
|
||||
showTopGraphDatasetsButton,
|
||||
isGraphRenderDatasetLimitActive,
|
||||
limitedGraphDatasetIds,
|
||||
selectableChartDatasets,
|
||||
showTopGraphDatasets,
|
||||
}
|
||||
}
|
||||
|
||||
function buildDatasetsByStat(
|
||||
timeSlices: Parameters<typeof buildChartDatasets>[0],
|
||||
selectedProjects: AnalyticsDashboardProject[],
|
||||
palette: string[],
|
||||
selectedBreakdowns: Parameters<typeof buildChartDatasets>[4],
|
||||
getVersionDisplayName: Parameters<typeof buildChartDatasets>[5],
|
||||
getVersionProjectName: Parameters<typeof buildChartDatasets>[6],
|
||||
formatMessage: FormatMessage,
|
||||
sliceCount: number,
|
||||
) {
|
||||
const datasetsByStat = {} as Record<AnalyticsDashboardStat, ChartDataset[]>
|
||||
for (const stat of ANALYTICS_DASHBOARD_STATS) {
|
||||
datasetsByStat[stat] = buildChartDatasets(
|
||||
timeSlices,
|
||||
selectedProjects,
|
||||
stat,
|
||||
palette,
|
||||
selectedBreakdowns,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
formatMessage,
|
||||
sliceCount,
|
||||
)
|
||||
}
|
||||
return datasetsByStat
|
||||
}
|
||||
|
||||
function sortDatasetsByTotal(datasets: ChartDataset[]) {
|
||||
return [...datasets]
|
||||
.sort((a, b) => {
|
||||
const totalDifference = getChartDatasetTotal(b) - getChartDatasetTotal(a)
|
||||
return (
|
||||
totalDifference || a.label.localeCompare(b.label) || a.projectId.localeCompare(b.projectId)
|
||||
)
|
||||
})
|
||||
.map((dataset) => dataset.projectId)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
type AnalyticsDashboardContextValue,
|
||||
doesProjectStatusMatchFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export function useAnalyticsChartProjects(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
'displayedSelectedProjectIds' | 'projects' | 'displayedSelectedFilters'
|
||||
>,
|
||||
) {
|
||||
const selectedProjectIdSet = computed(() => new Set(context.displayedSelectedProjectIds.value))
|
||||
const hasAvailableProjects = computed(() => context.projects.value.length > 0)
|
||||
|
||||
const selectedProjects = computed(() =>
|
||||
context.projects.value.filter(
|
||||
(project) =>
|
||||
selectedProjectIdSet.value.has(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, context.displayedSelectedFilters.value),
|
||||
),
|
||||
)
|
||||
const selectedProjectNameById = computed(
|
||||
() => new Map(selectedProjects.value.map((project) => [project.id, project.name])),
|
||||
)
|
||||
const selectedProjectEventIdSet = computed(
|
||||
() => new Set(selectedProjects.value.map((project) => project.id)),
|
||||
)
|
||||
|
||||
return {
|
||||
selectedProjectIdSet,
|
||||
hasAvailableProjects,
|
||||
selectedProjects,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
import { defineMessages, getLoaderMessage, type VIntlFormatters } from '@modrinth/ui'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGroupByPreset,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export type FormatMessage = VIntlFormatters['formatMessage']
|
||||
export type AnalyticsBreakdownItemType =
|
||||
| 'country'
|
||||
| 'downloadReason'
|
||||
| 'downloadSource'
|
||||
| 'gameVersion'
|
||||
| 'loader'
|
||||
| 'monetization'
|
||||
| 'project'
|
||||
| 'projectVersion'
|
||||
| 'other'
|
||||
|
||||
export const analyticsMessages = defineMessages({
|
||||
title: {
|
||||
id: 'analytics.title',
|
||||
defaultMessage: 'Analytics',
|
||||
},
|
||||
resetButton: {
|
||||
id: 'analytics.action.reset',
|
||||
defaultMessage: 'Reset',
|
||||
},
|
||||
refreshButton: {
|
||||
id: 'analytics.action.refresh',
|
||||
defaultMessage: 'Refresh',
|
||||
},
|
||||
fetchingResults: {
|
||||
id: 'analytics.loading.fetching-results',
|
||||
defaultMessage: 'Fetching results...',
|
||||
},
|
||||
allProjects: {
|
||||
id: 'analytics.project.all',
|
||||
defaultMessage: 'All projects',
|
||||
},
|
||||
yourProjects: {
|
||||
id: 'analytics.project.your',
|
||||
defaultMessage: 'Your projects',
|
||||
},
|
||||
userProjects: {
|
||||
id: 'analytics.project.user',
|
||||
defaultMessage: "{username}'s projects",
|
||||
},
|
||||
selectProjects: {
|
||||
id: 'analytics.project.select',
|
||||
defaultMessage: 'Select projects',
|
||||
},
|
||||
projectCount: {
|
||||
id: 'analytics.project.count',
|
||||
defaultMessage: '{count, plural, one {# project} other {# projects}}',
|
||||
},
|
||||
projectIconAlt: {
|
||||
id: 'analytics.project.icon-alt',
|
||||
defaultMessage: '{name} Icon',
|
||||
},
|
||||
noDataAvailable: {
|
||||
id: 'analytics.empty.no-data',
|
||||
defaultMessage: 'No data available',
|
||||
},
|
||||
noDataAvailableForAnalytics: {
|
||||
id: 'analytics.empty.no-data-for-analytics',
|
||||
defaultMessage: 'No data available for analytics',
|
||||
},
|
||||
noProjectsAvailable: {
|
||||
id: 'analytics.empty.no-projects',
|
||||
defaultMessage: 'No projects available',
|
||||
},
|
||||
noProjectsAvailableForAnalytics: {
|
||||
id: 'analytics.empty.no-projects-for-analytics',
|
||||
defaultMessage: 'No projects available for analytics',
|
||||
},
|
||||
selectAtLeastOneProject: {
|
||||
id: 'analytics.empty.select-project',
|
||||
defaultMessage: 'Select at least one project to view data',
|
||||
},
|
||||
unknown: {
|
||||
id: 'analytics.value.unknown',
|
||||
defaultMessage: 'Unknown',
|
||||
},
|
||||
other: {
|
||||
id: 'analytics.value.other',
|
||||
defaultMessage: 'Other',
|
||||
},
|
||||
none: {
|
||||
id: 'analytics.value.none',
|
||||
defaultMessage: 'None',
|
||||
},
|
||||
noBreakdown: {
|
||||
id: 'analytics.breakdown.none.selected',
|
||||
defaultMessage: 'No breakdown',
|
||||
},
|
||||
breakdownBy: {
|
||||
id: 'analytics.breakdown.selected',
|
||||
defaultMessage: 'Breakdown by {breakdown}',
|
||||
},
|
||||
projectLabel: {
|
||||
id: 'analytics.query.label.project',
|
||||
defaultMessage: 'Project:',
|
||||
},
|
||||
timeframeLabel: {
|
||||
id: 'analytics.query.label.timeframe',
|
||||
defaultMessage: 'Timeframe:',
|
||||
},
|
||||
groupedByLabel: {
|
||||
id: 'analytics.query.label.grouped-by',
|
||||
defaultMessage: 'Grouped by',
|
||||
},
|
||||
breakdownLabel: {
|
||||
id: 'analytics.query.label.breakdown',
|
||||
defaultMessage: 'Breakdown:',
|
||||
},
|
||||
addFilterButton: {
|
||||
id: 'analytics.query.filter.add',
|
||||
defaultMessage: 'Add filter',
|
||||
},
|
||||
addButton: {
|
||||
id: 'analytics.action.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
downloadsSuffix: {
|
||||
id: 'analytics.downloads.suffix',
|
||||
defaultMessage: 'downloads',
|
||||
},
|
||||
projectsAbove: {
|
||||
id: 'analytics.threshold.projects-above',
|
||||
defaultMessage: 'Projects above',
|
||||
},
|
||||
countriesAbove: {
|
||||
id: 'analytics.threshold.countries-above',
|
||||
defaultMessage: 'Countries above',
|
||||
},
|
||||
projectVersionsAbove: {
|
||||
id: 'analytics.threshold.project-versions-above',
|
||||
defaultMessage: 'Project versions above',
|
||||
},
|
||||
gameVersionsAbove: {
|
||||
id: 'analytics.threshold.game-versions-above',
|
||||
defaultMessage: 'Game versions above',
|
||||
},
|
||||
projectDownloadsThresholdAria: {
|
||||
id: 'analytics.threshold.project-downloads-aria',
|
||||
defaultMessage: 'Project downloads threshold',
|
||||
},
|
||||
countryDownloadsThresholdAria: {
|
||||
id: 'analytics.threshold.country-downloads-aria',
|
||||
defaultMessage: 'Country downloads threshold',
|
||||
},
|
||||
projectVersionDownloadsThresholdAria: {
|
||||
id: 'analytics.threshold.project-version-downloads-aria',
|
||||
defaultMessage: 'Project version downloads threshold',
|
||||
},
|
||||
gameVersionDownloadsThresholdAria: {
|
||||
id: 'analytics.threshold.game-version-downloads-aria',
|
||||
defaultMessage: 'Game version downloads threshold',
|
||||
},
|
||||
loadingOptions: {
|
||||
id: 'analytics.options.loading',
|
||||
defaultMessage: 'Loading...',
|
||||
},
|
||||
searchCountriesPlaceholder: {
|
||||
id: 'analytics.filter.search.countries',
|
||||
defaultMessage: 'Search countries...',
|
||||
},
|
||||
searchDownloadSourcesPlaceholder: {
|
||||
id: 'analytics.filter.search.download-sources',
|
||||
defaultMessage: 'Search download sources...',
|
||||
},
|
||||
searchProjectVersionsPlaceholder: {
|
||||
id: 'analytics.filter.search.project-versions',
|
||||
defaultMessage: 'Search project versions...',
|
||||
},
|
||||
searchVersionsPlaceholder: {
|
||||
id: 'analytics.filter.search.versions',
|
||||
defaultMessage: 'Search versions...',
|
||||
},
|
||||
gameVersionTypeAria: {
|
||||
id: 'analytics.filter.game-version-type',
|
||||
defaultMessage: 'Game version type',
|
||||
},
|
||||
releaseTab: {
|
||||
id: 'analytics.filter.game-version-type.release',
|
||||
defaultMessage: 'Release',
|
||||
},
|
||||
allTab: {
|
||||
id: 'analytics.filter.game-version-type.all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsStatMessages = defineMessages({
|
||||
views: {
|
||||
id: 'analytics.stat.views',
|
||||
defaultMessage: 'Views',
|
||||
},
|
||||
downloads: {
|
||||
id: 'analytics.stat.downloads',
|
||||
defaultMessage: 'Downloads',
|
||||
},
|
||||
revenue: {
|
||||
id: 'analytics.stat.revenue',
|
||||
defaultMessage: 'Revenue',
|
||||
},
|
||||
playtime: {
|
||||
id: 'analytics.stat.playtime',
|
||||
defaultMessage: 'Playtime',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsGraphTitleMessages = defineMessages({
|
||||
views: {
|
||||
id: 'analytics.graph.title.views',
|
||||
defaultMessage: 'Views Over Time',
|
||||
},
|
||||
downloads: {
|
||||
id: 'analytics.graph.title.downloads',
|
||||
defaultMessage: 'Downloads Over Time',
|
||||
},
|
||||
revenue: {
|
||||
id: 'analytics.graph.title.revenue',
|
||||
defaultMessage: 'Revenue Over Time',
|
||||
},
|
||||
playtime: {
|
||||
id: 'analytics.graph.title.playtime',
|
||||
defaultMessage: 'Playtime Over Time',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsStatCardMessages = defineMessages({
|
||||
monetizationBannerTitle: {
|
||||
id: 'analytics.stat.monetization-banner.title',
|
||||
defaultMessage: 'How does monetization work?',
|
||||
},
|
||||
monetizationBannerBody: {
|
||||
id: 'analytics.stat.monetization-banner.body',
|
||||
defaultMessage:
|
||||
'Only views and downloads made through Modrinth are eligible for monetization and must pass fraud-prevention filtering. Modrinth App downloads also require the user to be logged in. Because all projects have a similar ratio of monetized downloads, your revenue would not meaningfully change if all downloads were counted.',
|
||||
},
|
||||
monetizationBannerLearnMore: {
|
||||
id: 'analytics.stat.monetization-banner.learn-more',
|
||||
defaultMessage: 'Learn more',
|
||||
},
|
||||
revenueValue: {
|
||||
id: 'analytics.stat.revenue-value',
|
||||
defaultMessage: '${value}',
|
||||
},
|
||||
playtimeHours: {
|
||||
id: 'analytics.stat.playtime-hours',
|
||||
defaultMessage: '{hours} hrs',
|
||||
},
|
||||
unavailableTooltip: {
|
||||
id: 'analytics.stat.unavailable-tooltip',
|
||||
defaultMessage: 'Stat unavailable for current query',
|
||||
},
|
||||
unavailableLabel: {
|
||||
id: 'analytics.stat.unavailable',
|
||||
defaultMessage: 'N/A',
|
||||
},
|
||||
previousPeriodComparison: {
|
||||
id: 'analytics.stat.previous-period-comparison',
|
||||
defaultMessage: 'vs prev. period',
|
||||
},
|
||||
previousPeriodComparisonShort: {
|
||||
id: 'analytics.stat.previous-period-comparison-short',
|
||||
defaultMessage: 'vs prev.',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsGroupByMessages = defineMessages({
|
||||
oneHour: {
|
||||
id: 'analytics.group-by.1h',
|
||||
defaultMessage: '1h',
|
||||
},
|
||||
sixHours: {
|
||||
id: 'analytics.group-by.6h',
|
||||
defaultMessage: '6h',
|
||||
},
|
||||
day: {
|
||||
id: 'analytics.group-by.day',
|
||||
defaultMessage: 'Day',
|
||||
},
|
||||
week: {
|
||||
id: 'analytics.group-by.week',
|
||||
defaultMessage: 'Week',
|
||||
},
|
||||
month: {
|
||||
id: 'analytics.group-by.month',
|
||||
defaultMessage: 'Month',
|
||||
},
|
||||
year: {
|
||||
id: 'analytics.group-by.year',
|
||||
defaultMessage: 'Year',
|
||||
},
|
||||
date: {
|
||||
id: 'analytics.group-by.date',
|
||||
defaultMessage: 'Date',
|
||||
},
|
||||
groupByHour: {
|
||||
id: 'analytics.group-by.selected.hour',
|
||||
defaultMessage: 'Group by hour',
|
||||
},
|
||||
groupBySixHours: {
|
||||
id: 'analytics.group-by.selected.six-hours',
|
||||
defaultMessage: 'Group by 6 hours',
|
||||
},
|
||||
groupByDay: {
|
||||
id: 'analytics.group-by.selected.day',
|
||||
defaultMessage: 'Group by day',
|
||||
},
|
||||
groupByWeek: {
|
||||
id: 'analytics.group-by.selected.week',
|
||||
defaultMessage: 'Group by week',
|
||||
},
|
||||
groupByMonth: {
|
||||
id: 'analytics.group-by.selected.month',
|
||||
defaultMessage: 'Group by month',
|
||||
},
|
||||
groupByYear: {
|
||||
id: 'analytics.group-by.selected.year',
|
||||
defaultMessage: 'Group by year',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsBreakdownMessages = defineMessages({
|
||||
breakdown: {
|
||||
id: 'analytics.breakdown.generic',
|
||||
defaultMessage: 'Breakdown',
|
||||
},
|
||||
project: {
|
||||
id: 'analytics.breakdown.project',
|
||||
defaultMessage: 'Project',
|
||||
},
|
||||
country: {
|
||||
id: 'analytics.breakdown.country',
|
||||
defaultMessage: 'Country',
|
||||
},
|
||||
monetization: {
|
||||
id: 'analytics.breakdown.monetization',
|
||||
defaultMessage: 'Monetization',
|
||||
},
|
||||
userAgent: {
|
||||
id: 'analytics.breakdown.download-source',
|
||||
defaultMessage: 'Download source',
|
||||
},
|
||||
downloadReason: {
|
||||
id: 'analytics.breakdown.download-reason',
|
||||
defaultMessage: 'Download reason',
|
||||
},
|
||||
versionId: {
|
||||
id: 'analytics.breakdown.project-version',
|
||||
defaultMessage: 'Project version',
|
||||
},
|
||||
loader: {
|
||||
id: 'analytics.breakdown.loader',
|
||||
defaultMessage: 'Loader',
|
||||
},
|
||||
gameVersion: {
|
||||
id: 'analytics.breakdown.game-version',
|
||||
defaultMessage: 'Game version',
|
||||
},
|
||||
projectStatus: {
|
||||
id: 'analytics.breakdown.project-status',
|
||||
defaultMessage: 'Project status',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsMonetizationMessages = defineMessages({
|
||||
monetized: {
|
||||
id: 'analytics.value.monetized',
|
||||
defaultMessage: 'Monetized',
|
||||
},
|
||||
unmonetized: {
|
||||
id: 'analytics.value.unmonetized',
|
||||
defaultMessage: 'Unmonetized',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsDownloadReasonMessages = defineMessages({
|
||||
standalone: {
|
||||
id: 'analytics.download-reason.standalone',
|
||||
defaultMessage: 'Standalone',
|
||||
},
|
||||
dependency: {
|
||||
id: 'analytics.download-reason.dependency',
|
||||
defaultMessage: 'Dependency',
|
||||
},
|
||||
modpack: {
|
||||
id: 'analytics.download-reason.modpack',
|
||||
defaultMessage: 'Modpack',
|
||||
},
|
||||
update: {
|
||||
id: 'analytics.download-reason.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsDownloadSourceMessages = defineMessages({
|
||||
website: {
|
||||
id: 'analytics.download-source.website',
|
||||
defaultMessage: 'Modrinth Website',
|
||||
},
|
||||
app: {
|
||||
id: 'analytics.download-source.app',
|
||||
defaultMessage: 'Modrinth App',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsProjectStatusMessages = defineMessages({
|
||||
approved: {
|
||||
id: 'analytics.project-status.approved',
|
||||
defaultMessage: 'Approved',
|
||||
},
|
||||
archived: {
|
||||
id: 'analytics.project-status.archived',
|
||||
defaultMessage: 'Archived',
|
||||
},
|
||||
rejected: {
|
||||
id: 'analytics.project-status.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
draft: {
|
||||
id: 'analytics.project-status.draft',
|
||||
defaultMessage: 'Draft',
|
||||
},
|
||||
unlisted: {
|
||||
id: 'analytics.project-status.unlisted',
|
||||
defaultMessage: 'Unlisted',
|
||||
},
|
||||
withheld: {
|
||||
id: 'analytics.project-status.withheld',
|
||||
defaultMessage: 'Withheld',
|
||||
},
|
||||
private: {
|
||||
id: 'analytics.project-status.private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
other: {
|
||||
id: 'analytics.project-status.other',
|
||||
defaultMessage: 'Other',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsTableMessages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'analytics.table.search.placeholder',
|
||||
defaultMessage: 'Search...',
|
||||
},
|
||||
exportCsvButton: {
|
||||
id: 'analytics.table.export-csv',
|
||||
defaultMessage: 'Export CSV',
|
||||
},
|
||||
cumulativeCsv: {
|
||||
id: 'analytics.table.export.cumulative',
|
||||
defaultMessage: 'Cumulative',
|
||||
},
|
||||
groupedCsv: {
|
||||
id: 'analytics.table.export.grouped',
|
||||
defaultMessage: 'Grouped by {groupBy}',
|
||||
},
|
||||
noMatchingRows: {
|
||||
id: 'analytics.table.empty.no-matching-rows',
|
||||
defaultMessage: 'No matching analytics rows',
|
||||
},
|
||||
paginationSummary: {
|
||||
id: 'analytics.table.pagination.summary',
|
||||
defaultMessage: 'Showing {start} to {end} of {total}',
|
||||
},
|
||||
playtimeSecondsHeader: {
|
||||
id: 'analytics.table.csv.header.playtime-seconds',
|
||||
defaultMessage: 'Playtime (seconds)',
|
||||
},
|
||||
csvSelectedRange: {
|
||||
id: 'analytics.table.csv.selected-range',
|
||||
defaultMessage: 'Selected Range',
|
||||
},
|
||||
csvDateRange: {
|
||||
id: 'analytics.table.csv.date-range',
|
||||
defaultMessage: '{start} to {end}',
|
||||
},
|
||||
csvFilename: {
|
||||
id: 'analytics.table.csv.filename',
|
||||
defaultMessage: 'Modrinth Analytics {breakdown} Breakdown - {dateRange}',
|
||||
},
|
||||
durationDays: {
|
||||
id: 'analytics.table.duration.days',
|
||||
defaultMessage: '{count, plural, one {# day} other {# days}}',
|
||||
},
|
||||
durationHours: {
|
||||
id: 'analytics.table.duration.hours',
|
||||
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
|
||||
},
|
||||
durationMinutes: {
|
||||
id: 'analytics.table.duration.minutes',
|
||||
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsChartMessages = defineMessages({
|
||||
selectTableItemsEmpty: {
|
||||
id: 'analytics.chart.empty.select-table-items',
|
||||
defaultMessage: 'Select items from table below to visualize your data.',
|
||||
},
|
||||
showLimited: {
|
||||
id: 'analytics.chart.action.show-limited',
|
||||
defaultMessage: 'Show limited',
|
||||
},
|
||||
showAll: {
|
||||
id: 'analytics.chart.action.show-all',
|
||||
defaultMessage: 'Show all',
|
||||
},
|
||||
showTopEight: {
|
||||
id: 'analytics.chart.action.show-top-eight',
|
||||
defaultMessage: 'Show top 8',
|
||||
},
|
||||
tableSelectionLimited: {
|
||||
id: 'analytics.chart.table-selection.limited',
|
||||
defaultMessage:
|
||||
'Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionAll: {
|
||||
id: 'analytics.chart.table-selection.all',
|
||||
defaultMessage:
|
||||
'Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionTop: {
|
||||
id: 'analytics.chart.table-selection.top',
|
||||
defaultMessage:
|
||||
'Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionCount: {
|
||||
id: 'analytics.chart.table-selection.count',
|
||||
defaultMessage:
|
||||
'Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
lineView: {
|
||||
id: 'analytics.chart.view.line',
|
||||
defaultMessage: 'Line',
|
||||
},
|
||||
areaView: {
|
||||
id: 'analytics.chart.view.area',
|
||||
defaultMessage: 'Area',
|
||||
},
|
||||
barView: {
|
||||
id: 'analytics.chart.view.bar',
|
||||
defaultMessage: 'Bar',
|
||||
},
|
||||
controlsButton: {
|
||||
id: 'analytics.chart.controls.button',
|
||||
defaultMessage: 'Controls',
|
||||
},
|
||||
controlsAria: {
|
||||
id: 'analytics.chart.controls.aria',
|
||||
defaultMessage: 'Analytics graph controls, {activeCount}',
|
||||
},
|
||||
controlsDialogAria: {
|
||||
id: 'analytics.chart.controls.dialog-aria',
|
||||
defaultMessage: 'Analytics graph controls',
|
||||
},
|
||||
activeControlCount: {
|
||||
id: 'analytics.chart.controls.active-count',
|
||||
defaultMessage: '{count} active',
|
||||
},
|
||||
displayControls: {
|
||||
id: 'analytics.chart.controls.display',
|
||||
defaultMessage: 'Display',
|
||||
},
|
||||
previousPeriod: {
|
||||
id: 'analytics.chart.controls.previous-period',
|
||||
defaultMessage: 'Previous period',
|
||||
},
|
||||
ratio: {
|
||||
id: 'analytics.chart.controls.ratio',
|
||||
defaultMessage: 'Ratio',
|
||||
},
|
||||
annotations: {
|
||||
id: 'analytics.chart.controls.annotations',
|
||||
defaultMessage: 'Annotations',
|
||||
},
|
||||
projectEvents: {
|
||||
id: 'analytics.chart.controls.project-events',
|
||||
defaultMessage: 'Project events',
|
||||
},
|
||||
modrinthEvents: {
|
||||
id: 'analytics.chart.controls.modrinth-events',
|
||||
defaultMessage: 'Modrinth events',
|
||||
},
|
||||
noProjectEvents: {
|
||||
id: 'analytics.chart.controls.no-project-events',
|
||||
defaultMessage: 'No project events in graph.',
|
||||
},
|
||||
noModrinthEvents: {
|
||||
id: 'analytics.chart.controls.no-modrinth-events',
|
||||
defaultMessage: 'No Modrinth events in graph.',
|
||||
},
|
||||
viewMonetizedAnalyticsDetails: {
|
||||
id: 'analytics.chart.legend.monetization-details.aria',
|
||||
defaultMessage: 'View monetized analytics details',
|
||||
},
|
||||
monetizedAnalyticsDetails: {
|
||||
id: 'analytics.chart.legend.monetization-details.title',
|
||||
defaultMessage: 'Monetized analytics details',
|
||||
},
|
||||
monetizedAnalyticsDetailsDescription: {
|
||||
id: 'analytics.chart.legend.monetization-details.description',
|
||||
defaultMessage:
|
||||
'Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in.',
|
||||
},
|
||||
previousPeriodSuffix: {
|
||||
id: 'analytics.chart.legend.previous-period-suffix',
|
||||
defaultMessage: '{name} (Prev.)',
|
||||
},
|
||||
previousPeriodShort: {
|
||||
id: 'analytics.chart.tooltip.previous-period-short',
|
||||
defaultMessage: '(prev.)',
|
||||
},
|
||||
tooltipPinned: {
|
||||
id: 'analytics.chart.tooltip.pinned',
|
||||
defaultMessage: 'Chart tooltip pinned',
|
||||
},
|
||||
pinned: {
|
||||
id: 'analytics.chart.tooltip.pinned-aria',
|
||||
defaultMessage: 'Pinned',
|
||||
},
|
||||
total: {
|
||||
id: 'analytics.chart.tooltip.total',
|
||||
defaultMessage: 'Total',
|
||||
},
|
||||
showEntryInGraph: {
|
||||
id: 'analytics.chart.tooltip.show-entry',
|
||||
defaultMessage: 'Show {name} in graph',
|
||||
},
|
||||
hideEntryInGraph: {
|
||||
id: 'analytics.chart.tooltip.hide-entry',
|
||||
defaultMessage: 'Hide {name} in graph',
|
||||
},
|
||||
durationDays: {
|
||||
id: 'analytics.chart.tooltip.duration.days',
|
||||
defaultMessage: '{count, plural, one {# day} other {# days}}',
|
||||
},
|
||||
durationHours: {
|
||||
id: 'analytics.chart.tooltip.duration.hours',
|
||||
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
|
||||
},
|
||||
durationMinutes: {
|
||||
id: 'analytics.chart.tooltip.duration.minutes',
|
||||
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
|
||||
},
|
||||
playtimeAxisHours: {
|
||||
id: 'analytics.chart.axis.playtime-hours',
|
||||
defaultMessage: '{hours} h',
|
||||
},
|
||||
renderLimitHeader: {
|
||||
id: 'analytics.chart.render-limit.header',
|
||||
defaultMessage: 'Show all {count} lines in graph?',
|
||||
},
|
||||
renderLimitDescription: {
|
||||
id: 'analytics.chart.render-limit.description',
|
||||
defaultMessage: 'Showing all selected lines from table may degrade page performance.',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'analytics.action.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
analyticsEventsCount: {
|
||||
id: 'analytics.chart.events.count-aria',
|
||||
defaultMessage: '{count, plural, one {# analytics event} other {# analytics events}}',
|
||||
},
|
||||
seeAnnouncement: {
|
||||
id: 'analytics.chart.events.see-announcement',
|
||||
defaultMessage: 'See announcement',
|
||||
},
|
||||
projectEventTitle: {
|
||||
id: 'analytics.chart.events.project-title',
|
||||
defaultMessage: '<project>{projectName}</project>: {title}',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsProjectEventMessages = defineMessages({
|
||||
versionReleased: {
|
||||
id: 'analytics.project-event.version-released',
|
||||
defaultMessage: '{version} released',
|
||||
},
|
||||
versionUploaded: {
|
||||
id: 'analytics.project-event.version-uploaded',
|
||||
defaultMessage: 'Version uploaded',
|
||||
},
|
||||
projectApproved: {
|
||||
id: 'analytics.project-event.project-approved',
|
||||
defaultMessage: 'Project approved',
|
||||
},
|
||||
projectUnlisted: {
|
||||
id: 'analytics.project-event.project-unlisted',
|
||||
defaultMessage: 'Project unlisted',
|
||||
},
|
||||
projectPrivate: {
|
||||
id: 'analytics.project-event.project-private',
|
||||
defaultMessage: 'Project set to private',
|
||||
},
|
||||
projectStatusChanged: {
|
||||
id: 'analytics.project-event.project-status-changed',
|
||||
defaultMessage: 'Project status changed',
|
||||
},
|
||||
})
|
||||
|
||||
export function formatAnalyticsStatLabel(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatMessage(analyticsStatMessages[stat])
|
||||
}
|
||||
|
||||
export function formatAnalyticsGraphTitle(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatMessage(analyticsGraphTitleMessages[stat])
|
||||
}
|
||||
|
||||
export function formatAnalyticsGroupByLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (groupBy) {
|
||||
case '1h':
|
||||
return formatMessage(analyticsGroupByMessages.oneHour)
|
||||
case '6h':
|
||||
return formatMessage(analyticsGroupByMessages.sixHours)
|
||||
case 'day':
|
||||
return formatMessage(analyticsGroupByMessages.day)
|
||||
case 'week':
|
||||
return formatMessage(analyticsGroupByMessages.week)
|
||||
case 'month':
|
||||
return formatMessage(analyticsGroupByMessages.month)
|
||||
case 'year':
|
||||
return formatMessage(analyticsGroupByMessages.year)
|
||||
default:
|
||||
return formatMessage(analyticsGroupByMessages.date)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsGroupBySelectedLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (groupBy) {
|
||||
case '1h':
|
||||
return formatMessage(analyticsGroupByMessages.groupByHour)
|
||||
case '6h':
|
||||
return formatMessage(analyticsGroupByMessages.groupBySixHours)
|
||||
case 'day':
|
||||
return formatMessage(analyticsGroupByMessages.groupByDay)
|
||||
case 'week':
|
||||
return formatMessage(analyticsGroupByMessages.groupByWeek)
|
||||
case 'month':
|
||||
return formatMessage(analyticsGroupByMessages.groupByMonth)
|
||||
case 'year':
|
||||
return formatMessage(analyticsGroupByMessages.groupByYear)
|
||||
default:
|
||||
return formatMessage(analyticsGroupByMessages.groupByDay)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsBreakdownLabel(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (breakdown) {
|
||||
case 'none':
|
||||
case 'project':
|
||||
return formatMessage(analyticsBreakdownMessages.project)
|
||||
case 'country':
|
||||
return formatMessage(analyticsBreakdownMessages.country)
|
||||
case 'monetization':
|
||||
return formatMessage(analyticsBreakdownMessages.monetization)
|
||||
case 'user_agent':
|
||||
return formatMessage(analyticsBreakdownMessages.userAgent)
|
||||
case 'download_reason':
|
||||
return formatMessage(analyticsBreakdownMessages.downloadReason)
|
||||
case 'version_id':
|
||||
return formatMessage(analyticsBreakdownMessages.versionId)
|
||||
case 'loader':
|
||||
return formatMessage(analyticsBreakdownMessages.loader)
|
||||
case 'game_version':
|
||||
return formatMessage(analyticsBreakdownMessages.gameVersion)
|
||||
default:
|
||||
return formatMessage(analyticsBreakdownMessages.breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownItemType(
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
): AnalyticsBreakdownItemType {
|
||||
if (breakdowns.length !== 1) {
|
||||
return 'other'
|
||||
}
|
||||
|
||||
switch (breakdowns[0]) {
|
||||
case 'project':
|
||||
return 'project'
|
||||
case 'country':
|
||||
return 'country'
|
||||
case 'monetization':
|
||||
return 'monetization'
|
||||
case 'user_agent':
|
||||
return 'downloadSource'
|
||||
case 'download_reason':
|
||||
return 'downloadReason'
|
||||
case 'version_id':
|
||||
return 'projectVersion'
|
||||
case 'loader':
|
||||
return 'loader'
|
||||
case 'game_version':
|
||||
return 'gameVersion'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsMonetizationLabel(
|
||||
value: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case 'monetized':
|
||||
return formatMessage(analyticsMonetizationMessages.monetized)
|
||||
case 'unmonetized':
|
||||
return formatMessage(analyticsMonetizationMessages.unmonetized)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsDownloadReasonLabel(
|
||||
reason: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (reason.trim().toLowerCase()) {
|
||||
case 'standalone':
|
||||
return formatMessage(analyticsDownloadReasonMessages.standalone)
|
||||
case 'dependency':
|
||||
return formatMessage(analyticsDownloadReasonMessages.dependency)
|
||||
case 'modpack':
|
||||
return formatMessage(analyticsDownloadReasonMessages.modpack)
|
||||
case 'update':
|
||||
return formatMessage(analyticsDownloadReasonMessages.update)
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsDownloadSourceLabel(
|
||||
source: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const normalized = source.trim()
|
||||
const normalizedLowercase = normalized.toLowerCase()
|
||||
if (normalizedLowercase === 'website') {
|
||||
return formatMessage(analyticsDownloadSourceMessages.website)
|
||||
}
|
||||
if (normalizedLowercase === 'modrinth_app') {
|
||||
return formatMessage(analyticsDownloadSourceMessages.app)
|
||||
}
|
||||
if (!normalized.includes('_')) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return normalizedLowercase
|
||||
.split('_')
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function formatAnalyticsProjectStatusLabel(
|
||||
status: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (status.trim().toLowerCase()) {
|
||||
case 'approved':
|
||||
return formatMessage(analyticsProjectStatusMessages.approved)
|
||||
case 'archived':
|
||||
return formatMessage(analyticsProjectStatusMessages.archived)
|
||||
case 'rejected':
|
||||
return formatMessage(analyticsProjectStatusMessages.rejected)
|
||||
case 'draft':
|
||||
return formatMessage(analyticsProjectStatusMessages.draft)
|
||||
case 'unlisted':
|
||||
return formatMessage(analyticsProjectStatusMessages.unlisted)
|
||||
case 'withheld':
|
||||
return formatMessage(analyticsProjectStatusMessages.withheld)
|
||||
case 'private':
|
||||
return formatMessage(analyticsProjectStatusMessages.private)
|
||||
case 'other':
|
||||
return formatMessage(analyticsProjectStatusMessages.other)
|
||||
default:
|
||||
return capitalizeAnalyticsValue(status)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsLoaderLabel(loader: string, formatMessage: FormatMessage): string {
|
||||
const normalizedLoader = loader.trim()
|
||||
const loaderMessage = getLoaderMessage(normalizedLoader)
|
||||
return loaderMessage ? formatMessage(loaderMessage) : capitalizeAnalyticsValue(normalizedLoader)
|
||||
}
|
||||
|
||||
function capitalizeAnalyticsValue(value: string): string {
|
||||
const normalizedValue = value.trim()
|
||||
if (normalizedValue.length === 0) {
|
||||
return value
|
||||
}
|
||||
|
||||
return `${normalizedValue.charAt(0).toUpperCase()}${normalizedValue.slice(1)}`
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
import type { LocationQuery, LocationQueryValue, LocationQueryValueRaw } from 'vue-router'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGraphState,
|
||||
AnalyticsGraphViewMode,
|
||||
AnalyticsGroupByPreset,
|
||||
AnalyticsLastTimeframeUnit,
|
||||
AnalyticsQueryBuilderState,
|
||||
AnalyticsQueryFilterCategory,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
AnalyticsSelectedFilters,
|
||||
AnalyticsTableSortColumn,
|
||||
AnalyticsTableSortDirection,
|
||||
AnalyticsTableSortState,
|
||||
AnalyticsTimeframeMode,
|
||||
AnalyticsTimeframePreset,
|
||||
MutableRouteQuery,
|
||||
} from '~/providers/analytics/analytics-types'
|
||||
|
||||
export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'last_30_days'
|
||||
export const DEFAULT_TIMEFRAME_MODE: AnalyticsTimeframeMode = 'preset'
|
||||
export const DEFAULT_LAST_TIMEFRAME_AMOUNT = 1
|
||||
export const DEFAULT_LAST_TIMEFRAME_UNIT: AnalyticsLastTimeframeUnit = 'days'
|
||||
export const DEFAULT_GROUP_BY_PRESET: AnalyticsGroupByPreset = 'day'
|
||||
export const DEFAULT_BREAKDOWN_PRESET: AnalyticsBreakdownPreset = 'none'
|
||||
export const DEFAULT_ANALYTICS_DASHBOARD_STAT: AnalyticsDashboardStat = 'views'
|
||||
export const DEFAULT_ANALYTICS_GRAPH_VIEW_MODE: AnalyticsGraphViewMode = 'line'
|
||||
export const DEFAULT_ANALYTICS_GRAPH_RATIO_MODE = false
|
||||
export const DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY = true
|
||||
export const DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY = false
|
||||
export const MAX_ANALYTICS_BREAKDOWN_PRESETS = 2
|
||||
|
||||
const TIMEFRAME_PRESET_VALUES: AnalyticsTimeframePreset[] = [
|
||||
'today',
|
||||
'yesterday',
|
||||
'last_7_days',
|
||||
'last_14_days',
|
||||
'last_30_days',
|
||||
'last_90_days',
|
||||
'last_180_days',
|
||||
'year_to_date',
|
||||
'all_time',
|
||||
]
|
||||
|
||||
const TIMEFRAME_MODE_VALUES: AnalyticsTimeframeMode[] = [
|
||||
'preset',
|
||||
'last',
|
||||
'custom_range',
|
||||
'custom_datetime_range',
|
||||
]
|
||||
const LAST_TIMEFRAME_UNIT_VALUES: AnalyticsLastTimeframeUnit[] = [
|
||||
'hours',
|
||||
'days',
|
||||
'weeks',
|
||||
'months',
|
||||
]
|
||||
|
||||
const GROUP_BY_PRESET_VALUES: AnalyticsGroupByPreset[] = [
|
||||
'1h',
|
||||
'6h',
|
||||
'day',
|
||||
'week',
|
||||
'month',
|
||||
'year',
|
||||
]
|
||||
|
||||
const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [
|
||||
'none',
|
||||
'project',
|
||||
'country',
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'version_id',
|
||||
'loader',
|
||||
'game_version',
|
||||
]
|
||||
|
||||
const ANALYTICS_DASHBOARD_STAT_VALUES: AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
const ANALYTICS_GRAPH_VIEW_MODE_VALUES: AnalyticsGraphViewMode[] = ['line', 'area', 'bar']
|
||||
const ANALYTICS_TABLE_SORT_COLUMN_VALUES: AnalyticsTableSortColumn[] = [
|
||||
'date',
|
||||
'project',
|
||||
'breakdown',
|
||||
'breakdown_project',
|
||||
'breakdown_country',
|
||||
'breakdown_monetization',
|
||||
'breakdown_user_agent',
|
||||
'breakdown_download_reason',
|
||||
'breakdown_version_id',
|
||||
'breakdown_loader',
|
||||
'breakdown_game_version',
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
const ANALYTICS_TABLE_SORT_DIRECTION_VALUES: AnalyticsTableSortDirection[] = ['asc', 'desc']
|
||||
|
||||
const PROJECT_STATUS_FILTER_VALUES = [
|
||||
'approved',
|
||||
'archived',
|
||||
'rejected',
|
||||
'draft',
|
||||
'unlisted',
|
||||
'withheld',
|
||||
'private',
|
||||
'other',
|
||||
]
|
||||
|
||||
const QUERY_KEY_PROJECT_IDS = 'a_projects'
|
||||
const QUERY_KEY_TIMEFRAME_MODE = 'a_timeframe_mode'
|
||||
const QUERY_KEY_TIMEFRAME = 'a_timeframe'
|
||||
const QUERY_KEY_TIMEFRAME_LAST_AMOUNT = 'a_timeframe_last_amount'
|
||||
const QUERY_KEY_TIMEFRAME_LAST_UNIT = 'a_timeframe_last_unit'
|
||||
const QUERY_KEY_TIMEFRAME_START = 'a_timeframe_start'
|
||||
const QUERY_KEY_TIMEFRAME_END = 'a_timeframe_end'
|
||||
const QUERY_KEY_GROUP_BY = 'a_group_by'
|
||||
const QUERY_KEY_BREAKDOWN = 'a_breakdown'
|
||||
const QUERY_KEY_FILTER_PROJECT_STATUS = 'a_project_status'
|
||||
const QUERY_KEY_FILTER_COUNTRY = 'a_country'
|
||||
const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization'
|
||||
const QUERY_KEY_FILTER_USER_AGENT = 'a_user_agent'
|
||||
const QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE = 'a_download_source'
|
||||
const QUERY_KEY_FILTER_DOWNLOAD_REASON = 'a_download_reason'
|
||||
const QUERY_KEY_FILTER_VERSION_ID = 'a_version_id'
|
||||
const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version'
|
||||
const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type'
|
||||
const QUERY_KEY_STAT = 'a_stat'
|
||||
const QUERY_KEY_GRAPH_VIEW_MODE = 'a_chart'
|
||||
const QUERY_KEY_GRAPH_RATIO_MODE = 'a_ratio'
|
||||
const QUERY_KEY_GRAPH_EVENTS_VISIBILITY = 'a_events'
|
||||
const QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY = 'a_project_events'
|
||||
const QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY = 'a_prev_period'
|
||||
const QUERY_KEY_GRAPH_HIDDEN_SERIES = 'a_hidden_series'
|
||||
const QUERY_KEY_GRAPH_SELECTED_SERIES = 'a_selected_series'
|
||||
const QUERY_KEY_TABLE_SORT = 'a_table_sort'
|
||||
const QUERY_KEY_TABLE_SORT_DIRECTION = 'a_table_sort_direction'
|
||||
const QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER = 'a_top_breakdown'
|
||||
const QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION = 'a_legend_expanded'
|
||||
const PROJECT_SELECTION_ALL_QUERY_VALUE = 'all'
|
||||
|
||||
const URL_FILTER_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
|
||||
'project_status',
|
||||
'country',
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'version_id',
|
||||
'game_version',
|
||||
'loader_type',
|
||||
]
|
||||
|
||||
const FILTER_QUERY_KEY_BY_CATEGORY: Record<
|
||||
Exclude<AnalyticsQueryFilterCategory, 'project'>,
|
||||
string
|
||||
> = {
|
||||
project_status: QUERY_KEY_FILTER_PROJECT_STATUS,
|
||||
country: QUERY_KEY_FILTER_COUNTRY,
|
||||
monetization: QUERY_KEY_FILTER_MONETIZATION,
|
||||
user_agent: QUERY_KEY_FILTER_USER_AGENT,
|
||||
download_reason: QUERY_KEY_FILTER_DOWNLOAD_REASON,
|
||||
version_id: QUERY_KEY_FILTER_VERSION_ID,
|
||||
game_version: QUERY_KEY_FILTER_GAME_VERSION,
|
||||
loader_type: QUERY_KEY_FILTER_LOADER_TYPE,
|
||||
}
|
||||
|
||||
const ANALYTICS_QUERY_KEYS = [
|
||||
QUERY_KEY_PROJECT_IDS,
|
||||
QUERY_KEY_TIMEFRAME_MODE,
|
||||
QUERY_KEY_TIMEFRAME,
|
||||
QUERY_KEY_TIMEFRAME_LAST_AMOUNT,
|
||||
QUERY_KEY_TIMEFRAME_LAST_UNIT,
|
||||
QUERY_KEY_TIMEFRAME_START,
|
||||
QUERY_KEY_TIMEFRAME_END,
|
||||
QUERY_KEY_GROUP_BY,
|
||||
QUERY_KEY_BREAKDOWN,
|
||||
QUERY_KEY_FILTER_PROJECT_STATUS,
|
||||
QUERY_KEY_FILTER_COUNTRY,
|
||||
QUERY_KEY_FILTER_MONETIZATION,
|
||||
QUERY_KEY_FILTER_USER_AGENT,
|
||||
QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE,
|
||||
QUERY_KEY_FILTER_DOWNLOAD_REASON,
|
||||
QUERY_KEY_FILTER_VERSION_ID,
|
||||
QUERY_KEY_FILTER_GAME_VERSION,
|
||||
QUERY_KEY_FILTER_LOADER_TYPE,
|
||||
QUERY_KEY_STAT,
|
||||
QUERY_KEY_GRAPH_VIEW_MODE,
|
||||
QUERY_KEY_GRAPH_RATIO_MODE,
|
||||
QUERY_KEY_GRAPH_EVENTS_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_HIDDEN_SERIES,
|
||||
QUERY_KEY_GRAPH_SELECTED_SERIES,
|
||||
QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER,
|
||||
QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION,
|
||||
]
|
||||
|
||||
export function buildEmptySelectedFilters(): AnalyticsSelectedFilters {
|
||||
return {
|
||||
project: [],
|
||||
project_status: [],
|
||||
country: [],
|
||||
monetization: [],
|
||||
user_agent: [],
|
||||
download_reason: [],
|
||||
version_id: [],
|
||||
game_version: [],
|
||||
loader_type: [],
|
||||
}
|
||||
}
|
||||
|
||||
function parseListQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): string[] {
|
||||
if (value === undefined) return []
|
||||
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
const parsedValues: string[] = []
|
||||
for (const item of values) {
|
||||
if (!item) continue
|
||||
const parts = item.split(',')
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed.length > 0) {
|
||||
parsedValues.push(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(parsedValues))
|
||||
}
|
||||
|
||||
function parseSelectedSeriesQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): string[] {
|
||||
return parseListQueryValue(value).filter((item) => item.toLowerCase() !== 'null')
|
||||
}
|
||||
|
||||
function normalizeFilterQueryValues(
|
||||
category: Exclude<AnalyticsQueryFilterCategory, 'project'>,
|
||||
values: string[],
|
||||
): string[] {
|
||||
if (category === 'project_status') {
|
||||
return values
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => PROJECT_STATUS_FILTER_VALUES.includes(value))
|
||||
}
|
||||
|
||||
if (category !== 'loader_type') {
|
||||
return values
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)),
|
||||
)
|
||||
}
|
||||
|
||||
function parsePresetQueryValue<T extends string>(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
allowedValues: readonly T[],
|
||||
fallbackValue: T,
|
||||
): T {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue) return fallbackValue
|
||||
if (!allowedValues.includes(rawValue as T)) return fallbackValue
|
||||
return rawValue as T
|
||||
}
|
||||
|
||||
function parseAnalyticsBreakdownsQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValues: AnalyticsSelectedBreakdowns,
|
||||
): AnalyticsBreakdownPreset[] {
|
||||
const rawValues = parseListQueryValue(value)
|
||||
if (rawValues.length === 0) {
|
||||
return [...fallbackValues]
|
||||
}
|
||||
|
||||
const parsedBreakdowns: AnalyticsBreakdownPreset[] = []
|
||||
for (const rawValue of rawValues) {
|
||||
const normalizedValue = rawValue === 'download_source' ? 'user_agent' : rawValue
|
||||
if (BREAKDOWN_PRESET_VALUES.includes(normalizedValue as AnalyticsBreakdownPreset)) {
|
||||
parsedBreakdowns.push(normalizedValue as AnalyticsBreakdownPreset)
|
||||
}
|
||||
}
|
||||
|
||||
return parsedBreakdowns
|
||||
}
|
||||
|
||||
function parsePositiveIntegerQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: number,
|
||||
): number {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue) return fallbackValue
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isFinite(parsedValue) || parsedValue < 1) return fallbackValue
|
||||
return parsedValue
|
||||
}
|
||||
|
||||
function parseEnabledQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): boolean {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
return rawValue === '1'
|
||||
}
|
||||
|
||||
function parseVisibleQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: boolean,
|
||||
): boolean {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (rawValue === undefined) return fallbackValue
|
||||
return rawValue !== '0'
|
||||
}
|
||||
|
||||
function getLocalDateQueryValue(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getDefaultCustomStartDate(): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - 1)
|
||||
return getLocalDateQueryValue(date)
|
||||
}
|
||||
|
||||
function getDefaultCustomEndDate(): string {
|
||||
return getLocalDateQueryValue(new Date())
|
||||
}
|
||||
|
||||
function getDefaultCustomDateTimeValue(value: string): string {
|
||||
return new Date(`${value}T00:00:00`).toISOString()
|
||||
}
|
||||
|
||||
function parseDateQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: string,
|
||||
): string {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue || !/^\d{4}-\d{2}-\d{2}$/.test(rawValue)) return fallbackValue
|
||||
|
||||
const date = new Date(`${rawValue}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) return fallbackValue
|
||||
if (getLocalDateQueryValue(date) !== rawValue) return fallbackValue
|
||||
|
||||
return rawValue
|
||||
}
|
||||
|
||||
function parseDateTimeQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: string,
|
||||
): string {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue || !/^\d{4}-\d{2}-\d{2}T/.test(rawValue)) return fallbackValue
|
||||
|
||||
const date = new Date(rawValue)
|
||||
if (Number.isNaN(date.getTime())) return fallbackValue
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function isTimeframeRangeEndBeforeStart(
|
||||
mode: AnalyticsTimeframeMode,
|
||||
startValue: string,
|
||||
endValue: string,
|
||||
): boolean {
|
||||
if (mode === 'custom_datetime_range') {
|
||||
return new Date(endValue).getTime() < new Date(startValue).getTime()
|
||||
}
|
||||
|
||||
return endValue < startValue
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsGraphProjectEventsVisibility(
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): boolean {
|
||||
return selectedProjectIds.length <= 1
|
||||
}
|
||||
|
||||
export function buildDefaultAnalyticsGraphState(
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): AnalyticsGraphState {
|
||||
return {
|
||||
activeStat: DEFAULT_ANALYTICS_DASHBOARD_STAT,
|
||||
activeGraphViewMode: DEFAULT_ANALYTICS_GRAPH_VIEW_MODE,
|
||||
isRatioMode: DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
|
||||
showChartEvents: DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
|
||||
showProjectEvents: getDefaultAnalyticsGraphProjectEventsVisibility(selectedProjectIds),
|
||||
showPreviousPeriod: DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
hiddenGraphDatasetIds: [],
|
||||
selectedGraphDatasetIds: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefaultAnalyticsQueryBuilderState(
|
||||
availableProjectIds: string[],
|
||||
defaultProjectIds: string[] = availableProjectIds,
|
||||
): AnalyticsQueryBuilderState {
|
||||
return {
|
||||
selectedProjectIds: [...defaultProjectIds],
|
||||
selectedTimeframeMode: DEFAULT_TIMEFRAME_MODE,
|
||||
selectedTimeframe: DEFAULT_TIMEFRAME_PRESET,
|
||||
selectedLastTimeframeAmount: DEFAULT_LAST_TIMEFRAME_AMOUNT,
|
||||
selectedLastTimeframeUnit: DEFAULT_LAST_TIMEFRAME_UNIT,
|
||||
selectedCustomTimeframeStartDate: getDefaultCustomStartDate(),
|
||||
selectedCustomTimeframeEndDate: getDefaultCustomEndDate(),
|
||||
selectedGroupBy: DEFAULT_GROUP_BY_PRESET,
|
||||
selectedBreakdowns: getDefaultAnalyticsBreakdownPresets(defaultProjectIds),
|
||||
selectedFilters: buildEmptySelectedFilters(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsBreakdownPresets(
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsSelectedBreakdowns {
|
||||
return selectedProjectIds.length > 1 ? ['project'] : []
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsBreakdownPreset(
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsBreakdownPreset {
|
||||
return selectedProjectIds.length > 1 ? 'project' : DEFAULT_BREAKDOWN_PRESET
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsSelectedBreakdowns {
|
||||
const normalizedBreakdowns: AnalyticsSelectedBreakdowns = []
|
||||
const canBreakDownByProject = selectedProjectIds.length > 1
|
||||
|
||||
for (const breakdown of breakdowns) {
|
||||
if (breakdown === 'none') {
|
||||
continue
|
||||
}
|
||||
if (breakdown === 'project' && !canBreakDownByProject) {
|
||||
continue
|
||||
}
|
||||
if (!normalizedBreakdowns.includes(breakdown)) {
|
||||
normalizedBreakdowns.push(breakdown)
|
||||
}
|
||||
if (normalizedBreakdowns.length >= MAX_ANALYTICS_BREAKDOWN_PRESETS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedBreakdowns
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownPresetForProjectSelection(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsBreakdownPreset {
|
||||
const defaultBreakdown = getDefaultAnalyticsBreakdownPreset(selectedProjectIds)
|
||||
if (
|
||||
(breakdown === 'none' && defaultBreakdown === 'project') ||
|
||||
(breakdown === 'project' && defaultBreakdown === 'none')
|
||||
) {
|
||||
return defaultBreakdown
|
||||
}
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
export function isAnalyticsQueryBuilderStateDefault(
|
||||
state: AnalyticsQueryBuilderState,
|
||||
availableProjectIds: string[],
|
||||
defaultProjectIds: string[] = availableProjectIds,
|
||||
): boolean {
|
||||
const defaultState = buildDefaultAnalyticsQueryBuilderState(
|
||||
availableProjectIds,
|
||||
defaultProjectIds,
|
||||
)
|
||||
const areDefaultProjectsSelected =
|
||||
defaultProjectIds.length === 0
|
||||
? state.selectedProjectIds.length === 0
|
||||
: areAllProjectsSelected(state.selectedProjectIds, defaultProjectIds)
|
||||
|
||||
return (
|
||||
areDefaultProjectsSelected &&
|
||||
state.selectedTimeframeMode === defaultState.selectedTimeframeMode &&
|
||||
state.selectedTimeframe === defaultState.selectedTimeframe &&
|
||||
state.selectedLastTimeframeAmount === defaultState.selectedLastTimeframeAmount &&
|
||||
state.selectedLastTimeframeUnit === defaultState.selectedLastTimeframeUnit &&
|
||||
state.selectedCustomTimeframeStartDate === defaultState.selectedCustomTimeframeStartDate &&
|
||||
state.selectedCustomTimeframeEndDate === defaultState.selectedCustomTimeframeEndDate &&
|
||||
state.selectedGroupBy === defaultState.selectedGroupBy &&
|
||||
areStringArraysEqual(
|
||||
state.selectedBreakdowns,
|
||||
getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds),
|
||||
) &&
|
||||
areSelectedFiltersEqual(state.selectedFilters, defaultState.selectedFilters)
|
||||
)
|
||||
}
|
||||
|
||||
export function isAnalyticsGraphStateDefault(
|
||||
state: AnalyticsGraphState,
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): boolean {
|
||||
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
|
||||
|
||||
return (
|
||||
state.activeStat === defaultState.activeStat &&
|
||||
state.activeGraphViewMode === defaultState.activeGraphViewMode &&
|
||||
state.isRatioMode === defaultState.isRatioMode &&
|
||||
state.showChartEvents === defaultState.showChartEvents &&
|
||||
state.showProjectEvents === defaultState.showProjectEvents &&
|
||||
state.showPreviousPeriod === defaultState.showPreviousPeriod &&
|
||||
areStringArraysEqual(state.hiddenGraphDatasetIds, defaultState.hiddenGraphDatasetIds) &&
|
||||
state.selectedGraphDatasetIds === defaultState.selectedGraphDatasetIds
|
||||
)
|
||||
}
|
||||
|
||||
function serializeListQueryValue(values: string[]): string | undefined {
|
||||
if (values.length === 0) return undefined
|
||||
return values.join(',')
|
||||
}
|
||||
|
||||
function serializeExplicitListQueryValue(values: string[]): string {
|
||||
return values.join(',')
|
||||
}
|
||||
|
||||
function serializeVisibleQueryValue(value: boolean, defaultValue: boolean): string | undefined {
|
||||
if (value === defaultValue) return undefined
|
||||
return value ? '1' : '0'
|
||||
}
|
||||
|
||||
function normalizeQueryValue(
|
||||
value:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
): string[] {
|
||||
if (value === undefined || value === null) return []
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter(
|
||||
(item): item is LocationQueryValue | LocationQueryValueRaw =>
|
||||
item !== undefined && item !== null,
|
||||
)
|
||||
.map((item) => String(item))
|
||||
}
|
||||
return [String(value)]
|
||||
}
|
||||
|
||||
function areQueryValuesEqual(
|
||||
left:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
right:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
): boolean {
|
||||
const leftValues = normalizeQueryValue(left)
|
||||
const rightValues = normalizeQueryValue(right)
|
||||
|
||||
if (leftValues.length !== rightValues.length) return false
|
||||
for (let index = 0; index < leftValues.length; index += 1) {
|
||||
if (leftValues[index] !== rightValues[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function areSelectedFiltersEqual(
|
||||
left: AnalyticsSelectedFilters,
|
||||
right: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
if (!areStringArraysEqual(left.project, right.project)) return false
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
if (!areStringArraysEqual(left[category], right[category])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function areAllProjectsSelected(selectedProjectIds: string[], allProjectIds: string[]): boolean {
|
||||
if (allProjectIds.length === 0 || selectedProjectIds.length !== allProjectIds.length) {
|
||||
return false
|
||||
}
|
||||
const allProjectIdSet = new Set(allProjectIds)
|
||||
return selectedProjectIds.every((projectId) => allProjectIdSet.has(projectId))
|
||||
}
|
||||
|
||||
export function readAnalyticsGraphState(
|
||||
query: LocationQuery,
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): AnalyticsGraphState {
|
||||
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
|
||||
|
||||
return {
|
||||
activeStat: parsePresetQueryValue(
|
||||
query[QUERY_KEY_STAT],
|
||||
ANALYTICS_DASHBOARD_STAT_VALUES,
|
||||
defaultState.activeStat,
|
||||
),
|
||||
activeGraphViewMode: parsePresetQueryValue(
|
||||
query[QUERY_KEY_GRAPH_VIEW_MODE],
|
||||
ANALYTICS_GRAPH_VIEW_MODE_VALUES,
|
||||
defaultState.activeGraphViewMode,
|
||||
),
|
||||
isRatioMode: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_RATIO_MODE]),
|
||||
showChartEvents: parseVisibleQueryValue(
|
||||
query[QUERY_KEY_GRAPH_EVENTS_VISIBILITY],
|
||||
defaultState.showChartEvents,
|
||||
),
|
||||
showProjectEvents: parseVisibleQueryValue(
|
||||
query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY],
|
||||
defaultState.showProjectEvents,
|
||||
),
|
||||
showPreviousPeriod: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY]),
|
||||
hiddenGraphDatasetIds: parseListQueryValue(query[QUERY_KEY_GRAPH_HIDDEN_SERIES]),
|
||||
selectedGraphDatasetIds:
|
||||
query[QUERY_KEY_GRAPH_SELECTED_SERIES] === undefined
|
||||
? null
|
||||
: parseSelectedSeriesQueryValue(query[QUERY_KEY_GRAPH_SELECTED_SERIES]),
|
||||
}
|
||||
}
|
||||
|
||||
export function readAnalyticsTableSortState(
|
||||
query: LocationQuery,
|
||||
defaultState: AnalyticsTableSortState,
|
||||
): AnalyticsTableSortState {
|
||||
const rawSortColumn = Array.isArray(query[QUERY_KEY_TABLE_SORT])
|
||||
? query[QUERY_KEY_TABLE_SORT][0]
|
||||
: query[QUERY_KEY_TABLE_SORT]
|
||||
const rawSortDirection = Array.isArray(query[QUERY_KEY_TABLE_SORT_DIRECTION])
|
||||
? query[QUERY_KEY_TABLE_SORT_DIRECTION][0]
|
||||
: query[QUERY_KEY_TABLE_SORT_DIRECTION]
|
||||
|
||||
if (
|
||||
!rawSortColumn ||
|
||||
!rawSortDirection ||
|
||||
!ANALYTICS_TABLE_SORT_COLUMN_VALUES.includes(rawSortColumn as AnalyticsTableSortColumn) ||
|
||||
!ANALYTICS_TABLE_SORT_DIRECTION_VALUES.includes(rawSortDirection as AnalyticsTableSortDirection)
|
||||
) {
|
||||
return defaultState
|
||||
}
|
||||
|
||||
return {
|
||||
sortColumn: rawSortColumn as AnalyticsTableSortColumn,
|
||||
sortDirection: rawSortDirection as AnalyticsTableSortDirection,
|
||||
}
|
||||
}
|
||||
|
||||
export function readAnalyticsQueryBuilderState(
|
||||
query: LocationQuery,
|
||||
availableProjectIds: string[],
|
||||
defaultProjectIds: string[] = availableProjectIds,
|
||||
): AnalyticsQueryBuilderState {
|
||||
const defaultState = buildDefaultAnalyticsQueryBuilderState(
|
||||
availableProjectIds,
|
||||
defaultProjectIds,
|
||||
)
|
||||
const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS])
|
||||
let selectedProjectIds = defaultState.selectedProjectIds
|
||||
if (selectedProjectIdsFromQuery.includes(PROJECT_SELECTION_ALL_QUERY_VALUE)) {
|
||||
selectedProjectIds = [...availableProjectIds]
|
||||
} else if (selectedProjectIdsFromQuery.length > 0) {
|
||||
selectedProjectIds = selectedProjectIdsFromQuery
|
||||
}
|
||||
|
||||
const selectedFilters = buildEmptySelectedFilters()
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
|
||||
const rawQueryValue =
|
||||
category === 'user_agent' && query[categoryQueryKey] === undefined
|
||||
? query[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE]
|
||||
: query[categoryQueryKey]
|
||||
selectedFilters[category] = normalizeFilterQueryValues(
|
||||
category,
|
||||
parseListQueryValue(rawQueryValue),
|
||||
)
|
||||
}
|
||||
|
||||
const selectedTimeframeMode = parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_MODE],
|
||||
TIMEFRAME_MODE_VALUES,
|
||||
defaultState.selectedTimeframeMode,
|
||||
)
|
||||
const isCustomDateTimeRange = selectedTimeframeMode === 'custom_datetime_range'
|
||||
const parseTimeframeRangeQueryValue = isCustomDateTimeRange
|
||||
? parseDateTimeQueryValue
|
||||
: parseDateQueryValue
|
||||
const customTimeframeStartFallback = isCustomDateTimeRange
|
||||
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeStartDate)
|
||||
: defaultState.selectedCustomTimeframeStartDate
|
||||
const customTimeframeEndFallback = isCustomDateTimeRange
|
||||
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeEndDate)
|
||||
: defaultState.selectedCustomTimeframeEndDate
|
||||
|
||||
const selectedCustomTimeframeStartDate = parseTimeframeRangeQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_START],
|
||||
customTimeframeStartFallback,
|
||||
)
|
||||
const rawCustomTimeframeEndDate = parseTimeframeRangeQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_END],
|
||||
customTimeframeEndFallback,
|
||||
)
|
||||
const selectedCustomTimeframeEndDate = isTimeframeRangeEndBeforeStart(
|
||||
selectedTimeframeMode,
|
||||
selectedCustomTimeframeStartDate,
|
||||
rawCustomTimeframeEndDate,
|
||||
)
|
||||
? selectedCustomTimeframeStartDate
|
||||
: rawCustomTimeframeEndDate
|
||||
|
||||
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
parseAnalyticsBreakdownsQueryValue(
|
||||
query[QUERY_KEY_BREAKDOWN],
|
||||
getDefaultAnalyticsBreakdownPresets(selectedProjectIds),
|
||||
),
|
||||
selectedProjectIds,
|
||||
)
|
||||
|
||||
return {
|
||||
selectedProjectIds,
|
||||
selectedTimeframeMode,
|
||||
selectedTimeframe: parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME],
|
||||
TIMEFRAME_PRESET_VALUES,
|
||||
defaultState.selectedTimeframe,
|
||||
),
|
||||
selectedLastTimeframeAmount: parsePositiveIntegerQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_LAST_AMOUNT],
|
||||
defaultState.selectedLastTimeframeAmount,
|
||||
),
|
||||
selectedLastTimeframeUnit: parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_LAST_UNIT],
|
||||
LAST_TIMEFRAME_UNIT_VALUES,
|
||||
defaultState.selectedLastTimeframeUnit,
|
||||
),
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy: parsePresetQueryValue(
|
||||
query[QUERY_KEY_GROUP_BY],
|
||||
GROUP_BY_PRESET_VALUES,
|
||||
defaultState.selectedGroupBy,
|
||||
),
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAnalyticsBreakdownQuery(query: LocationQuery): boolean {
|
||||
return parseListQueryValue(query[QUERY_KEY_BREAKDOWN]).length > 0
|
||||
}
|
||||
|
||||
export function hasAnalyticsProjectSelectionQuery(query: LocationQuery): boolean {
|
||||
return parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]).length > 0
|
||||
}
|
||||
|
||||
export function hasAnalyticsGraphProjectEventsVisibilityQuery(query: LocationQuery): boolean {
|
||||
return query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] !== undefined
|
||||
}
|
||||
|
||||
export function hasAnalyticsTableSortQuery(query: LocationQuery): boolean {
|
||||
return (
|
||||
query[QUERY_KEY_TABLE_SORT] !== undefined || query[QUERY_KEY_TABLE_SORT_DIRECTION] !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function buildAnalyticsQueryBuilderRouteQuery(
|
||||
currentRouteQuery: LocationQuery,
|
||||
state: AnalyticsQueryBuilderState,
|
||||
availableProjectIds: string[],
|
||||
graphState?: AnalyticsGraphState,
|
||||
defaultProjectIds: string[] = availableProjectIds,
|
||||
): MutableRouteQuery {
|
||||
const nextRouteQuery = {
|
||||
...currentRouteQuery,
|
||||
} as MutableRouteQuery
|
||||
|
||||
const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, defaultProjectIds)
|
||||
? undefined
|
||||
: areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
|
||||
? PROJECT_SELECTION_ALL_QUERY_VALUE
|
||||
: serializeListQueryValue(state.selectedProjectIds)
|
||||
const isCustomTimeframeMode =
|
||||
state.selectedTimeframeMode === 'custom_range' ||
|
||||
state.selectedTimeframeMode === 'custom_datetime_range'
|
||||
|
||||
nextRouteQuery[QUERY_KEY_PROJECT_IDS] = projectIdsQueryValue
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_MODE] =
|
||||
state.selectedTimeframeMode !== DEFAULT_TIMEFRAME_MODE ? state.selectedTimeframeMode : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME] =
|
||||
state.selectedTimeframeMode === 'preset' && state.selectedTimeframe !== DEFAULT_TIMEFRAME_PRESET
|
||||
? state.selectedTimeframe
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_AMOUNT] =
|
||||
state.selectedTimeframeMode === 'last' ? String(state.selectedLastTimeframeAmount) : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_UNIT] =
|
||||
state.selectedTimeframeMode === 'last' ? state.selectedLastTimeframeUnit : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_START] = isCustomTimeframeMode
|
||||
? state.selectedCustomTimeframeStartDate
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_END] = isCustomTimeframeMode
|
||||
? state.selectedCustomTimeframeEndDate
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_GROUP_BY] =
|
||||
state.selectedGroupBy !== DEFAULT_GROUP_BY_PRESET ? state.selectedGroupBy : undefined
|
||||
const defaultBreakdowns = getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds)
|
||||
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
state.selectedBreakdowns,
|
||||
state.selectedProjectIds,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_BREAKDOWN] = areStringArraysEqual(selectedBreakdowns, defaultBreakdowns)
|
||||
? undefined
|
||||
: selectedBreakdowns.length === 0
|
||||
? 'none'
|
||||
: serializeListQueryValue(selectedBreakdowns)
|
||||
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
|
||||
nextRouteQuery[categoryQueryKey] = serializeListQueryValue(state.selectedFilters[category])
|
||||
}
|
||||
nextRouteQuery[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE] = undefined
|
||||
|
||||
if (graphState) {
|
||||
const defaultGraphState = buildDefaultAnalyticsGraphState(state.selectedProjectIds)
|
||||
|
||||
nextRouteQuery[QUERY_KEY_STAT] =
|
||||
graphState.activeStat !== DEFAULT_ANALYTICS_DASHBOARD_STAT ? graphState.activeStat : undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_VIEW_MODE] =
|
||||
graphState.activeGraphViewMode !== DEFAULT_ANALYTICS_GRAPH_VIEW_MODE
|
||||
? graphState.activeGraphViewMode
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_RATIO_MODE] = graphState.isRatioMode ? '1' : undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
|
||||
graphState.showChartEvents,
|
||||
defaultGraphState.showChartEvents,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
|
||||
graphState.showProjectEvents,
|
||||
defaultGraphState.showProjectEvents,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY] = graphState.showPreviousPeriod
|
||||
? '1'
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER] = undefined
|
||||
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION] = undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_HIDDEN_SERIES] = serializeListQueryValue(
|
||||
[...graphState.hiddenGraphDatasetIds].sort((left, right) => left.localeCompare(right)),
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_SELECTED_SERIES] =
|
||||
graphState.selectedGraphDatasetIds === null
|
||||
? undefined
|
||||
: serializeExplicitListQueryValue(graphState.selectedGraphDatasetIds)
|
||||
}
|
||||
|
||||
return nextRouteQuery
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableSortRouteQuery(
|
||||
currentRouteQuery: LocationQuery,
|
||||
state: AnalyticsTableSortState,
|
||||
defaultState: AnalyticsTableSortState,
|
||||
): MutableRouteQuery {
|
||||
const nextRouteQuery = {
|
||||
...currentRouteQuery,
|
||||
} as MutableRouteQuery
|
||||
const isDefaultSort =
|
||||
state.sortColumn === defaultState.sortColumn &&
|
||||
state.sortDirection === defaultState.sortDirection
|
||||
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT] =
|
||||
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortColumn
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION] =
|
||||
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortDirection
|
||||
|
||||
return nextRouteQuery
|
||||
}
|
||||
|
||||
export function hasAnalyticsQueryBuilderRouteChange(
|
||||
currentRouteQuery: LocationQuery,
|
||||
nextRouteQuery: MutableRouteQuery,
|
||||
): boolean {
|
||||
return ANALYTICS_QUERY_KEYS.some(
|
||||
(key) => !areQueryValuesEqual(currentRouteQuery[key], nextRouteQuery[key]),
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAnalyticsTableSortRouteChange(
|
||||
currentRouteQuery: LocationQuery,
|
||||
nextRouteQuery: MutableRouteQuery,
|
||||
): boolean {
|
||||
return (
|
||||
!areQueryValuesEqual(
|
||||
currentRouteQuery[QUERY_KEY_TABLE_SORT],
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT],
|
||||
) ||
|
||||
!areQueryValuesEqual(
|
||||
currentRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
|
||||
)
|
||||
)
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsSelectedFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsGroupByMessages,
|
||||
formatAnalyticsBreakdownLabel,
|
||||
formatAnalyticsStatLabel,
|
||||
type FormatMessage,
|
||||
} from '../analytics-messages'
|
||||
import type {
|
||||
AnalyticsTableBreakdownColumnKey,
|
||||
AnalyticsTableBreakdownPreset,
|
||||
AnalyticsTableColumnKey,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type BuildAnalyticsTableColumnsOptions = {
|
||||
includeDate: boolean
|
||||
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
|
||||
selectedFilters: AnalyticsSelectedFilters
|
||||
showBreakdownColumn: boolean
|
||||
showProjectVersionProjectColumn: boolean
|
||||
formatMessage: FormatMessage
|
||||
getRelevantAnalyticsDashboardStats: (
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
filters?: AnalyticsSelectedFilters,
|
||||
) => readonly AnalyticsDashboardStat[]
|
||||
}
|
||||
|
||||
export function getAnalyticsTableBreakdownColumnLabel(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatAnalyticsBreakdownLabel(breakdown, formatMessage)
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableColumns({
|
||||
includeDate,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
showBreakdownColumn,
|
||||
showProjectVersionProjectColumn,
|
||||
formatMessage,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
}: BuildAnalyticsTableColumnsOptions): TableColumn<AnalyticsTableColumnKey>[] {
|
||||
const nextColumns: TableColumn<AnalyticsTableColumnKey>[] = []
|
||||
const stats = getRelevantAnalyticsDashboardStats(selectedBreakdowns, selectedFilters)
|
||||
|
||||
if (includeDate) {
|
||||
nextColumns.push({
|
||||
key: 'date',
|
||||
label: formatMessage(analyticsGroupByMessages.date),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
width: stats.length > 2 ? '20%' : '',
|
||||
})
|
||||
}
|
||||
|
||||
if (showBreakdownColumn) {
|
||||
for (const breakdown of selectedBreakdowns) {
|
||||
nextColumns.push({
|
||||
key: getAnalyticsTableBreakdownColumnKey(breakdown),
|
||||
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
|
||||
enableSorting: true,
|
||||
width: breakdown === 'project' ? '25%' : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (showProjectVersionProjectColumn) {
|
||||
nextColumns.push({
|
||||
key: 'project',
|
||||
label: formatAnalyticsBreakdownLabel('project', formatMessage),
|
||||
enableSorting: true,
|
||||
width: '25%',
|
||||
})
|
||||
}
|
||||
|
||||
for (const stat of stats) {
|
||||
const column = getAnalyticsTableMetricColumn(stat, formatMessage)
|
||||
if (column) {
|
||||
nextColumns.push(column)
|
||||
}
|
||||
}
|
||||
|
||||
return nextColumns
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricColumn(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): TableColumn<AnalyticsTableColumnKey> | null {
|
||||
switch (stat) {
|
||||
case 'views':
|
||||
return {
|
||||
key: 'views',
|
||||
label: formatAnalyticsStatLabel('views', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'downloads':
|
||||
return {
|
||||
key: 'downloads',
|
||||
label: formatAnalyticsStatLabel('downloads', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'revenue':
|
||||
return {
|
||||
key: 'revenue',
|
||||
label: formatAnalyticsStatLabel('revenue', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'playtime':
|
||||
return {
|
||||
key: 'playtime',
|
||||
label: formatAnalyticsStatLabel('playtime', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsTableBreakdownColumnKey(
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
): AnalyticsTableBreakdownColumnKey {
|
||||
return `breakdown_${breakdown}`
|
||||
}
|
||||
|
||||
export function isAnalyticsTableBreakdownColumnKey(
|
||||
key: AnalyticsTableColumnKey,
|
||||
): key is AnalyticsTableBreakdownColumnKey {
|
||||
return key.startsWith('breakdown_')
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import { analyticsTableMessages, type FormatMessage } from '../analytics-messages'
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
export function buildAnalyticsTableCsvContent(
|
||||
rows: AnalyticsTableRow[],
|
||||
visibleColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const header = visibleColumns
|
||||
.map((column) =>
|
||||
escapeAnalyticsTableCsvField(getAnalyticsTableCsvHeaderLabel(column, formatMessage)),
|
||||
)
|
||||
.join(',')
|
||||
|
||||
const csvRows = rows.map((row) =>
|
||||
visibleColumns
|
||||
.map((column) => escapeAnalyticsTableCsvField(getAnalyticsTableCsvCellValue(row, column.key)))
|
||||
.join(','),
|
||||
)
|
||||
|
||||
return [header, ...csvRows].join('\n')
|
||||
}
|
||||
|
||||
export function downloadAnalyticsTableCsv(filename: string, csvContent: string) {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const downloadLink = document.createElement('a')
|
||||
downloadLink.setAttribute('href', url)
|
||||
downloadLink.setAttribute('download', filename)
|
||||
downloadLink.style.visibility = 'hidden'
|
||||
|
||||
document.body.appendChild(downloadLink)
|
||||
downloadLink.click()
|
||||
document.body.removeChild(downloadLink)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function getAnalyticsTableCsvFilename(
|
||||
breakdownColumnLabel: string,
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return `${sanitizeAnalyticsTableCsvFilename(
|
||||
formatMessage(analyticsTableMessages.csvFilename, {
|
||||
breakdown: breakdownColumnLabel,
|
||||
dateRange: getAnalyticsTableCsvFilenameDateRange(fetchRequest, formatMessage),
|
||||
}),
|
||||
)}.csv`
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvCellValue(
|
||||
row: AnalyticsTableRow,
|
||||
key: AnalyticsTableColumnKey,
|
||||
): string | number {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return row.date
|
||||
case 'project':
|
||||
return row.project
|
||||
case 'breakdown':
|
||||
return row.breakdownDisplay
|
||||
case 'views':
|
||||
return row.views
|
||||
case 'downloads':
|
||||
return row.downloads
|
||||
case 'revenue':
|
||||
return row.revenue
|
||||
case 'playtime':
|
||||
return row.playtime
|
||||
default:
|
||||
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvHeaderLabel(
|
||||
column: TableColumn<AnalyticsTableColumnKey>,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
if (column.key === 'playtime') {
|
||||
return formatMessage(analyticsTableMessages.playtimeSecondsHeader)
|
||||
}
|
||||
|
||||
return column.label ?? column.key
|
||||
}
|
||||
|
||||
function escapeAnalyticsTableCsvField(value: string | number): string {
|
||||
const stringValue = String(value)
|
||||
if (
|
||||
stringValue.includes(',') ||
|
||||
stringValue.includes('"') ||
|
||||
stringValue.includes('\n') ||
|
||||
stringValue.includes('\r')
|
||||
) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`
|
||||
}
|
||||
return stringValue
|
||||
}
|
||||
|
||||
function formatAnalyticsTableCsvFilenameDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvFilenameDateRange(
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const timeRange = fetchRequest?.time_range
|
||||
if (!timeRange) {
|
||||
return formatMessage(analyticsTableMessages.csvSelectedRange)
|
||||
}
|
||||
|
||||
const start = new Date(timeRange.start)
|
||||
const end = new Date(timeRange.end)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return formatMessage(analyticsTableMessages.csvSelectedRange)
|
||||
}
|
||||
|
||||
const startLabel = formatAnalyticsTableCsvFilenameDate(start)
|
||||
const endLabel = formatAnalyticsTableCsvFilenameDate(end)
|
||||
return startLabel === endLabel
|
||||
? startLabel
|
||||
: formatMessage(analyticsTableMessages.csvDateRange, {
|
||||
start: startLabel,
|
||||
end: endLabel,
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeAnalyticsTableCsvFilename(value: string): string {
|
||||
return value
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsStatCardMessages,
|
||||
analyticsTableMessages,
|
||||
formatAnalyticsGroupByLabel,
|
||||
type FormatMessage,
|
||||
} from '../analytics-messages'
|
||||
|
||||
const SECONDS_PER_MINUTE = 60
|
||||
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
|
||||
|
||||
export function getAnalyticsTableGroupByLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatAnalyticsGroupByLabel(groupBy, formatMessage)
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableInteger(
|
||||
formatNumber: (value: number) => string,
|
||||
value: number,
|
||||
): string {
|
||||
return formatNumber(Math.round(value))
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableRevenue(
|
||||
formatter: Intl.NumberFormat,
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const rounded = Math.round(value * 100) / 100
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatter.format(rounded),
|
||||
})
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableCompactPlaytime(
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const totalSeconds = Math.max(0, Math.round(value))
|
||||
const hours = totalSeconds / SECONDS_PER_HOUR
|
||||
const fractionDigits = hours < 1 ? 2 : 1
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: hours.toLocaleString(undefined, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableFullPlaytime(
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const totalMinutes = Math.max(0, Math.round(value / SECONDS_PER_MINUTE))
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
|
||||
return [
|
||||
formatMessage(analyticsTableMessages.durationDays, { count: days }),
|
||||
formatMessage(analyticsTableMessages.durationHours, { count: hours }),
|
||||
formatMessage(analyticsTableMessages.durationMinutes, { count: minutes }),
|
||||
].join(', ')
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
formatBreakdownLabel,
|
||||
formatBucketEndLabel,
|
||||
getSliceBucketRange,
|
||||
getSliceCount,
|
||||
} from '../analytics-chart/analytics-chart-utils'
|
||||
import type { FormatMessage } from '../analytics-messages'
|
||||
import { analyticsMessages } from '../analytics-messages'
|
||||
import {
|
||||
ALL_BREAKDOWN_VALUE,
|
||||
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
|
||||
getAnalyticsBreakdownDatasetId,
|
||||
getAnalyticsBreakdownKey,
|
||||
getAnalyticsBreakdownValues,
|
||||
} from '../breakdown'
|
||||
import { getAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type {
|
||||
AnalyticsTableBreakdownDisplayValues,
|
||||
AnalyticsTableBreakdownPreset,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableRow,
|
||||
} from './analytics-table-types'
|
||||
|
||||
const ALL_PROJECTS_BREAKDOWN_VALUE = 'all'
|
||||
|
||||
type BuildAnalyticsTableRowsOptions = {
|
||||
mode: AnalyticsTableMode
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[]
|
||||
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
|
||||
selectedProjectIds: ReadonlySet<string>
|
||||
relevantStats: ReadonlySet<AnalyticsDashboardStat>
|
||||
projectNamesById: ReadonlyMap<string, string>
|
||||
getVersionDisplayName: (versionId: string) => string
|
||||
getVersionProjectName: (versionId: string) => string | undefined
|
||||
showTimeInBucketLabel: boolean
|
||||
showYearInBucketLabel: boolean
|
||||
formatMessage: FormatMessage
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableRows({
|
||||
mode,
|
||||
fetchRequest,
|
||||
timeSlices,
|
||||
selectedBreakdowns,
|
||||
selectedProjectIds,
|
||||
relevantStats,
|
||||
projectNamesById,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
showTimeInBucketLabel,
|
||||
showYearInBucketLabel,
|
||||
formatMessage,
|
||||
}: BuildAnalyticsTableRowsOptions): AnalyticsTableRow[] {
|
||||
if (!fetchRequest || selectedProjectIds.size === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const timeRange = fetchRequest.time_range
|
||||
const sliceCount = getSliceCount(timeRange, timeSlices.length)
|
||||
const currentTimeSlices =
|
||||
timeSlices.length > sliceCount ? timeSlices.slice(timeSlices.length - sliceCount) : timeSlices
|
||||
const includeDate = mode === 'date_breakdown'
|
||||
const breakdownDisplayValues = new Map<string, string>()
|
||||
const projectDisplayValues = new Map<string, string>()
|
||||
const nextRows = new Map<string, AnalyticsTableRow>()
|
||||
const bucketLabelsBySliceIndex = new Map<number, { date: string; dateMs: number }>()
|
||||
|
||||
function getBreakdownDisplayValue(
|
||||
breakdownValue: string,
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
) {
|
||||
const key = `${breakdown}:${breakdownValue}`
|
||||
let displayValue = breakdownDisplayValues.get(key)
|
||||
if (displayValue === undefined) {
|
||||
displayValue = formatAnalyticsTableBreakdownDisplayValue(
|
||||
breakdownValue,
|
||||
breakdown,
|
||||
projectNamesById,
|
||||
getVersionDisplayName,
|
||||
formatMessage,
|
||||
)
|
||||
breakdownDisplayValues.set(key, displayValue)
|
||||
}
|
||||
return displayValue
|
||||
}
|
||||
|
||||
function getProjectDisplayValueForBreakdownValues(breakdownValues: readonly string[]) {
|
||||
const versionBreakdownIndex = selectedBreakdowns.indexOf('version_id')
|
||||
if (versionBreakdownIndex === -1 || selectedBreakdowns.includes('project')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const versionId = breakdownValues[versionBreakdownIndex]
|
||||
if (!versionId) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let displayValue = projectDisplayValues.get(versionId)
|
||||
if (displayValue === undefined) {
|
||||
displayValue = getVersionProjectName(versionId) ?? ''
|
||||
projectDisplayValues.set(versionId, displayValue)
|
||||
}
|
||||
return displayValue
|
||||
}
|
||||
|
||||
function getBreakdownDisplays(breakdownValues: readonly string[]) {
|
||||
const displays: AnalyticsTableBreakdownDisplayValues = {}
|
||||
|
||||
selectedBreakdowns.forEach((breakdown, index) => {
|
||||
displays[breakdown] = getBreakdownDisplayValue(breakdownValues[index] ?? '', breakdown)
|
||||
})
|
||||
|
||||
return displays
|
||||
}
|
||||
|
||||
function getCombinedBreakdownDisplay(displays: AnalyticsTableBreakdownDisplayValues) {
|
||||
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
|
||||
let hasUnknownBreakdownLabel = false
|
||||
|
||||
return selectedBreakdowns
|
||||
.map((breakdown) => displays[breakdown])
|
||||
.filter((displayValue): displayValue is string => Boolean(displayValue))
|
||||
.filter((displayValue) => {
|
||||
if (displayValue !== unknownBreakdownLabel) {
|
||||
return true
|
||||
}
|
||||
if (hasUnknownBreakdownLabel) {
|
||||
return false
|
||||
}
|
||||
hasUnknownBreakdownLabel = true
|
||||
return true
|
||||
})
|
||||
.join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
||||
}
|
||||
|
||||
function getBucketLabel(sliceIndex: number) {
|
||||
let bucketLabel = bucketLabelsBySliceIndex.get(sliceIndex)
|
||||
if (!bucketLabel) {
|
||||
const bucketRange = getSliceBucketRange(timeRange, sliceCount, sliceIndex)
|
||||
bucketLabel = {
|
||||
date: formatBucketEndLabel(bucketRange.end, showTimeInBucketLabel, showYearInBucketLabel),
|
||||
dateMs: bucketRange.end.getTime(),
|
||||
}
|
||||
bucketLabelsBySliceIndex.set(sliceIndex, bucketLabel)
|
||||
}
|
||||
return bucketLabel
|
||||
}
|
||||
|
||||
function createRow(
|
||||
rowId: string,
|
||||
breakdownValues: readonly string[],
|
||||
bucketLabel?: { date: string; dateMs: number },
|
||||
) {
|
||||
const breakdownKey =
|
||||
breakdownValues.length === 0
|
||||
? ALL_PROJECTS_BREAKDOWN_VALUE
|
||||
: getAnalyticsBreakdownKey(breakdownValues)
|
||||
const breakdownDisplays = getBreakdownDisplays(breakdownValues)
|
||||
const row: AnalyticsTableRow = {
|
||||
id: rowId,
|
||||
date: bucketLabel?.date ?? '',
|
||||
dateMs: bucketLabel?.dateMs ?? 0,
|
||||
project: getProjectDisplayValueForBreakdownValues(breakdownValues),
|
||||
breakdown: breakdownKey,
|
||||
breakdownValues: Object.fromEntries(
|
||||
selectedBreakdowns.map((breakdown, index) => [breakdown, breakdownValues[index] ?? '']),
|
||||
) as AnalyticsTableBreakdownDisplayValues,
|
||||
breakdownDisplays,
|
||||
graphDatasetId: getAnalyticsTableGraphDatasetId(breakdownValues, selectedBreakdowns),
|
||||
breakdownDisplay: getCombinedBreakdownDisplay(breakdownDisplays),
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
revenue: 0,
|
||||
playtime: 0,
|
||||
}
|
||||
|
||||
for (const breakdown of selectedBreakdowns) {
|
||||
row[getAnalyticsTableBreakdownColumnKey(breakdown)] = breakdownDisplays[breakdown] ?? ''
|
||||
}
|
||||
|
||||
nextRows.set(rowId, row)
|
||||
return row
|
||||
}
|
||||
|
||||
if (!includeDate && selectedBreakdowns.length === 0) {
|
||||
createRow(ALL_PROJECTS_BREAKDOWN_VALUE, [])
|
||||
}
|
||||
|
||||
if (!includeDate && selectedBreakdowns.length === 1 && selectedBreakdowns[0] === 'project') {
|
||||
for (const projectId of selectedProjectIds) {
|
||||
createRow(projectId, [projectId])
|
||||
}
|
||||
}
|
||||
|
||||
currentTimeSlices.forEach((slice, sliceIndex) => {
|
||||
const bucketLabel = includeDate ? getBucketLabel(sliceIndex) : undefined
|
||||
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPoint(point)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!selectedProjectIds.has(point.source_project)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pointStat = getAnalyticsTableStatForMetric(point.metric_kind)
|
||||
if (!pointStat || !relevantStats.has(pointStat)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const breakdownValues =
|
||||
selectedBreakdowns.length === 0
|
||||
? []
|
||||
: getAnalyticsBreakdownValues(point, selectedBreakdowns, formatMessage)
|
||||
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextBucketLabel = includeDate ? (bucketLabel ?? getBucketLabel(sliceIndex)) : undefined
|
||||
const breakdownKey =
|
||||
breakdownValues.length === 0
|
||||
? ALL_PROJECTS_BREAKDOWN_VALUE
|
||||
: getAnalyticsBreakdownKey(breakdownValues)
|
||||
const rowId = includeDate ? `${nextBucketLabel?.dateMs ?? 0}::${breakdownKey}` : breakdownKey
|
||||
const row = nextRows.get(rowId) ?? createRow(rowId, breakdownValues, nextBucketLabel)
|
||||
addAnalyticsMetricToTableRow(row, point)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(nextRows.values())
|
||||
}
|
||||
|
||||
function isProjectAnalyticsPoint(
|
||||
point: Labrinth.Analytics.v3.AnalyticsData,
|
||||
): point is Labrinth.Analytics.v3.ProjectAnalytics {
|
||||
return 'source_project' in point
|
||||
}
|
||||
|
||||
function addAnalyticsMetricToTableRow(
|
||||
row: AnalyticsTableRow,
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
) {
|
||||
switch (point.metric_kind) {
|
||||
case 'views':
|
||||
row.views += point.views
|
||||
break
|
||||
case 'downloads':
|
||||
row.downloads += point.downloads
|
||||
break
|
||||
case 'playtime':
|
||||
row.playtime += point.seconds
|
||||
break
|
||||
case 'revenue': {
|
||||
const parsed = Number.parseFloat(point.revenue)
|
||||
row.revenue += Number.isFinite(parsed) ? parsed : 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableStatForMetric(
|
||||
metricKind: Labrinth.Analytics.v3.ProjectAnalytics['metric_kind'],
|
||||
): AnalyticsDashboardStat | null {
|
||||
switch (metricKind) {
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
case 'revenue':
|
||||
case 'playtime':
|
||||
return metricKind
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableGraphDatasetId(
|
||||
breakdownValues: readonly string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
): string {
|
||||
return getAnalyticsBreakdownDatasetId(breakdownValues, selectedBreakdowns)
|
||||
}
|
||||
|
||||
function formatAnalyticsTableBreakdownDisplayValue(
|
||||
value: string,
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
projectNamesById: ReadonlyMap<string, string>,
|
||||
getVersionDisplayName: (versionId: string) => string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
if (breakdown === 'project') {
|
||||
return projectNamesById.get(value) ?? value
|
||||
}
|
||||
return formatBreakdownLabel(value, breakdown, getVersionDisplayName, formatMessage)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
const SEARCHABLE_COLUMN_KEYS = new Set<AnalyticsTableColumnKey>(['date', 'project'])
|
||||
|
||||
export function getAnalyticsTableSearchableColumns(
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
): TableColumn<AnalyticsTableColumnKey>[] {
|
||||
return columns.filter(
|
||||
(column) =>
|
||||
SEARCHABLE_COLUMN_KEYS.has(column.key) || isAnalyticsTableBreakdownColumnKey(column.key),
|
||||
)
|
||||
}
|
||||
|
||||
export function filterAnalyticsTableRowsBySearch(
|
||||
rows: AnalyticsTableRow[],
|
||||
searchableColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
query: string,
|
||||
): AnalyticsTableRow[] {
|
||||
if (!query || searchableColumns.length === 0) {
|
||||
return rows
|
||||
}
|
||||
|
||||
return rows.filter((row) =>
|
||||
searchableColumns.some((column) =>
|
||||
String(getAnalyticsTableSearchableCellValue(row, column.key)).toLowerCase().includes(query),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function getAnalyticsTableSearchableCellValue(
|
||||
row: AnalyticsTableRow,
|
||||
key: AnalyticsTableColumnKey,
|
||||
): string {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return row.date
|
||||
case 'project':
|
||||
return row.project
|
||||
case 'breakdown':
|
||||
return row.breakdownDisplay
|
||||
default:
|
||||
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
buildAnalyticsTableSortRouteQuery,
|
||||
readAnalyticsTableSortState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import {
|
||||
getAnalyticsTableDefaultSortColumn,
|
||||
getAnalyticsTableDefaultSortDirection,
|
||||
} from './analytics-table-sorting'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
AnalyticsTableSortState,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type GetDefaultAnalyticsTableSortStateOptions = {
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[]
|
||||
showGraphDatasetSelection: boolean
|
||||
activeStat: AnalyticsDashboardStat
|
||||
}
|
||||
|
||||
export function getRouteAnalyticsTableSortState(
|
||||
query: LocationQuery,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
): AnalyticsTableSortState {
|
||||
return getAvailableAnalyticsTableSortState(
|
||||
readAnalyticsTableSortState(query, getDefaultAnalyticsTableSortState(defaultSortOptions)),
|
||||
columns,
|
||||
defaultSortOptions,
|
||||
)
|
||||
}
|
||||
|
||||
export function getAvailableAnalyticsTableSortState(
|
||||
state: AnalyticsTableSortState,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
): AnalyticsTableSortState {
|
||||
const availableColumns = new Set(columns.map((column) => column.key))
|
||||
if (state.sortColumn && availableColumns.has(state.sortColumn)) {
|
||||
return state
|
||||
}
|
||||
if (state.sortColumn === 'breakdown') {
|
||||
const firstBreakdownColumn = columns.find((column) =>
|
||||
isAnalyticsTableBreakdownColumnKey(column.key),
|
||||
)
|
||||
if (firstBreakdownColumn) {
|
||||
return {
|
||||
sortColumn: firstBreakdownColumn.key,
|
||||
sortDirection: state.sortDirection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultAnalyticsTableSortState(defaultSortOptions)
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsTableSortState({
|
||||
columns,
|
||||
showGraphDatasetSelection,
|
||||
activeStat,
|
||||
}: GetDefaultAnalyticsTableSortStateOptions): AnalyticsTableSortState {
|
||||
const nextSortColumn = getAnalyticsTableDefaultSortColumn(
|
||||
columns,
|
||||
showGraphDatasetSelection,
|
||||
activeStat,
|
||||
)
|
||||
return {
|
||||
sortColumn: nextSortColumn,
|
||||
sortDirection: getAnalyticsTableDefaultSortDirection(nextSortColumn, columns),
|
||||
}
|
||||
}
|
||||
|
||||
export function areAnalyticsTableSortStatesEqual(
|
||||
left: AnalyticsTableSortState,
|
||||
right: AnalyticsTableSortState,
|
||||
): boolean {
|
||||
return left.sortColumn === right.sortColumn && left.sortDirection === right.sortDirection
|
||||
}
|
||||
|
||||
export function buildSyncedAnalyticsTableSortRouteQuery(
|
||||
query: LocationQuery,
|
||||
sortState: AnalyticsTableSortState,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
) {
|
||||
const nextSortState = getAvailableAnalyticsTableSortState(sortState, columns, defaultSortOptions)
|
||||
|
||||
return buildAnalyticsTableSortRouteQuery(
|
||||
query,
|
||||
nextSortState,
|
||||
getDefaultAnalyticsTableSortState(defaultSortOptions),
|
||||
)
|
||||
}
|
||||
|
||||
export function toAnalyticsTableSortState(
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortDirection: AnalyticsTableSortDirectionValue,
|
||||
): AnalyticsTableSortState {
|
||||
return {
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableRow,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types'
|
||||
|
||||
export function sortAnalyticsTableRows(
|
||||
rows: AnalyticsTableRow[],
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortDirection: AnalyticsTableSortDirectionValue,
|
||||
sortCollator: Intl.Collator,
|
||||
): AnalyticsTableRow[] {
|
||||
const nextRows = [...rows]
|
||||
|
||||
if (!sortColumn) {
|
||||
return nextRows
|
||||
}
|
||||
|
||||
const directionFactor = sortDirection === 'asc' ? 1 : -1
|
||||
nextRows.sort(getAnalyticsTableRowComparator(sortColumn, directionFactor, sortCollator))
|
||||
|
||||
return nextRows
|
||||
}
|
||||
|
||||
export function getAnalyticsTableDefaultSortColumn(
|
||||
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
showGraphDatasetSelection: boolean,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
): AnalyticsTableColumnKey | undefined {
|
||||
const availableColumns = new Set(nextColumns.map((column) => column.key))
|
||||
if (availableColumns.has('date')) {
|
||||
return 'date'
|
||||
}
|
||||
|
||||
if (showGraphDatasetSelection && availableColumns.has(activeStat)) {
|
||||
return activeStat
|
||||
}
|
||||
|
||||
if (availableColumns.has('downloads')) {
|
||||
return 'downloads'
|
||||
}
|
||||
|
||||
return nextColumns[0]?.key
|
||||
}
|
||||
|
||||
export function getAnalyticsTableDefaultSortDirection(
|
||||
column: AnalyticsTableColumnKey | undefined,
|
||||
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
): AnalyticsTableSortDirectionValue {
|
||||
return nextColumns.find((nextColumn) => nextColumn.key === column)?.defaultSortDirection ?? 'asc'
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricSortedGraphDatasetIds(
|
||||
rows: AnalyticsTableRow[],
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortCollator: Intl.Collator,
|
||||
): string[] {
|
||||
const metricColumn = getAnalyticsTableMetricSortColumn(sortColumn)
|
||||
if (!metricColumn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const totalsByGraphDatasetId = new Map<string, number>()
|
||||
const labelsByGraphDatasetId = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
totalsByGraphDatasetId.set(
|
||||
row.graphDatasetId,
|
||||
(totalsByGraphDatasetId.get(row.graphDatasetId) ?? 0) + row[metricColumn],
|
||||
)
|
||||
if (!labelsByGraphDatasetId.has(row.graphDatasetId)) {
|
||||
labelsByGraphDatasetId.set(row.graphDatasetId, row.breakdownDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(totalsByGraphDatasetId.keys()).sort((left, right) => {
|
||||
const totalDifference =
|
||||
(totalsByGraphDatasetId.get(right) ?? 0) - (totalsByGraphDatasetId.get(left) ?? 0)
|
||||
return (
|
||||
totalDifference ||
|
||||
sortCollator.compare(
|
||||
labelsByGraphDatasetId.get(left) ?? left,
|
||||
labelsByGraphDatasetId.get(right) ?? right,
|
||||
) ||
|
||||
left.localeCompare(right)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricSortColumn(
|
||||
column: AnalyticsTableColumnKey | undefined,
|
||||
): AnalyticsDashboardStat | null {
|
||||
switch (column) {
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
case 'revenue':
|
||||
case 'playtime':
|
||||
return column
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableRowComparator(
|
||||
column: AnalyticsTableColumnKey,
|
||||
directionFactor: number,
|
||||
sortCollator: Intl.Collator,
|
||||
): (left: AnalyticsTableRow, right: AnalyticsTableRow) => number {
|
||||
switch (column) {
|
||||
case 'date':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.dateMs - right.dateMs,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'project':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(left.project, right.project),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'breakdown':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(left.breakdownDisplay, right.breakdownDisplay),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'views':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.views - right.views,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'downloads':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.downloads - right.downloads,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'revenue':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.revenue - right.revenue,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'playtime':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.playtime - right.playtime,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
default:
|
||||
if (isAnalyticsTableBreakdownColumnKey(column)) {
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(String(left[column] ?? ''), String(right[column] ?? '')),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
}
|
||||
|
||||
return () => 0
|
||||
}
|
||||
}
|
||||
|
||||
function compareAnalyticsTableRows(
|
||||
left: AnalyticsTableRow,
|
||||
right: AnalyticsTableRow,
|
||||
primaryResult: number,
|
||||
directionFactor: number,
|
||||
sortCollator: Intl.Collator,
|
||||
): number {
|
||||
if (primaryResult !== 0) {
|
||||
return primaryResult * directionFactor
|
||||
}
|
||||
|
||||
const dateResult = left.dateMs - right.dateMs
|
||||
if (dateResult !== 0) {
|
||||
return dateResult * directionFactor
|
||||
}
|
||||
|
||||
return sortCollator.compare(left.breakdown, right.breakdown) * directionFactor
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsTableSortColumn,
|
||||
AnalyticsTableSortDirection,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export type AnalyticsTableMode = 'date_breakdown' | 'breakdown_only'
|
||||
export type AnalyticsTableBreakdownPreset = Exclude<AnalyticsBreakdownPreset, 'none'>
|
||||
export type AnalyticsTableBreakdownColumnKey = `breakdown_${AnalyticsTableBreakdownPreset}`
|
||||
export type AnalyticsTableBreakdownDisplayValues = Partial<
|
||||
Record<AnalyticsTableBreakdownPreset, string>
|
||||
>
|
||||
export type AnalyticsTableColumnKey = AnalyticsTableSortColumn
|
||||
export type AnalyticsTableSortState = {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirection
|
||||
}
|
||||
export type AnalyticsTableSortDirectionValue = AnalyticsTableSortDirection
|
||||
|
||||
export type AnalyticsTableRow = {
|
||||
[key: string]: string | number | AnalyticsTableBreakdownDisplayValues
|
||||
id: string
|
||||
date: string
|
||||
dateMs: number
|
||||
project: string
|
||||
breakdown: string
|
||||
breakdownValues: AnalyticsTableBreakdownDisplayValues
|
||||
breakdownDisplays: AnalyticsTableBreakdownDisplayValues
|
||||
graphDatasetId: string
|
||||
breakdownDisplay: string
|
||||
views: number
|
||||
downloads: number
|
||||
revenue: number
|
||||
playtime: number
|
||||
}
|
||||
|
||||
export type AnalyticsTableDisplayedRowsCache = {
|
||||
generation: number
|
||||
mode: AnalyticsTableMode
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
rows: AnalyticsTableRow[]
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden rounded-2xl">
|
||||
<AnalyticsLoadingBar :loading="isDataLoading" />
|
||||
<Table
|
||||
v-model:selected-ids="tableSelectedGraphDatasetIds"
|
||||
:sort-column="displayedSortColumn"
|
||||
:sort-direction="displayedSortDirection"
|
||||
:columns="columns"
|
||||
:data="paginatedRows"
|
||||
row-key="id"
|
||||
selection-key="graphDatasetId"
|
||||
:selection-ids="filteredSelectableGraphDatasetIds"
|
||||
:show-selection="showGraphDatasetSelection"
|
||||
table-min-width="44rem"
|
||||
virtualized
|
||||
:virtual-row-height="56"
|
||||
@sort="applyRequestedSort"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(analyticsBreakdownMessages.breakdown) }}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(analyticsTableMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full sm:w-64"
|
||||
@focusin="selectSearchInputText"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
class="!shadow-none"
|
||||
:options="csvExportOptions"
|
||||
:disabled="isDataLoading || filteredRows.length === 0"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(analyticsTableMessages.exportCsvButton) }}
|
||||
<DropdownIcon />
|
||||
<template #cumulative-csv>
|
||||
{{ formatMessage(analyticsTableMessages.cumulativeCsv) }}
|
||||
</template>
|
||||
<template #grouped-csv>
|
||||
{{ formatMessage(analyticsTableMessages.groupedCsv, { groupBy: groupByLabel }) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-date="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_project="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_country="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_monetization="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_user_agent="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_download_reason="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_version_id="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_loader="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_game_version="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-project="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-views="{ row }">
|
||||
<span>{{ formatInteger(row.views) }}</span>
|
||||
</template>
|
||||
<template #cell-downloads="{ row }">
|
||||
<span>{{ formatInteger(row.downloads) }}</span>
|
||||
</template>
|
||||
<template #cell-revenue="{ row }">
|
||||
<span>{{ formatRevenue(row.revenue) }}</span>
|
||||
</template>
|
||||
<template #cell-playtime="{ row }">
|
||||
<span v-tooltip="formatFullPlaytime(row.playtime)">
|
||||
{{ formatCompactPlaytime(row.playtime) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="flex h-64 items-center justify-center text-secondary">
|
||||
{{ !isDataLoading ? emptyTableMessage : '' }}
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div
|
||||
v-if="filteredRows.length > PAGE_SIZE"
|
||||
class="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 text-sm text-secondary"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
formatMessage(analyticsTableMessages.paginationSummary, {
|
||||
start: visibleRowStart,
|
||||
end: visibleRowEnd,
|
||||
total: filteredRows.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
<div v-if="isDataLoading" class="absolute inset-0 z-10 overflow-hidden rounded-xl">
|
||||
<div class="absolute inset-0 bg-surface-3 opacity-50" />
|
||||
<div class="absolute inset-0 backdrop-blur-[4px]" />
|
||||
<div class="absolute inset-0 flex h-full max-h-[500px] items-center justify-center pt-10">
|
||||
<div class="inline-flex items-center gap-2 text-lg font-semibold text-primary opacity-100">
|
||||
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
Table,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
hasAnalyticsTableSortQuery,
|
||||
hasAnalyticsTableSortRouteChange,
|
||||
readAnalyticsTableSortState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import {
|
||||
doesProjectStatusMatchFilters,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
isTimeRelevantForGroupBy,
|
||||
isYearRelevantForTimeRange,
|
||||
} from '../analytics-chart/analytics-chart-utils.ts'
|
||||
import {
|
||||
analyticsBreakdownMessages,
|
||||
analyticsMessages,
|
||||
analyticsTableMessages,
|
||||
} from '../analytics-messages.ts'
|
||||
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
|
||||
import {
|
||||
buildAnalyticsTableColumns,
|
||||
getAnalyticsTableBreakdownColumnLabel,
|
||||
} from './analytics-table-columns.ts'
|
||||
import {
|
||||
buildAnalyticsTableCsvContent,
|
||||
downloadAnalyticsTableCsv,
|
||||
getAnalyticsTableCsvFilename,
|
||||
} from './analytics-table-csv-export.ts'
|
||||
import {
|
||||
formatAnalyticsTableCompactPlaytime,
|
||||
formatAnalyticsTableFullPlaytime,
|
||||
formatAnalyticsTableInteger,
|
||||
formatAnalyticsTableRevenue,
|
||||
getAnalyticsTableGroupByLabel,
|
||||
} from './analytics-table-formatting.ts'
|
||||
import { buildAnalyticsTableRows } from './analytics-table-row-builder.ts'
|
||||
import {
|
||||
filterAnalyticsTableRowsBySearch,
|
||||
getAnalyticsTableSearchableColumns,
|
||||
} from './analytics-table-search-filtering.ts'
|
||||
import {
|
||||
areAnalyticsTableSortStatesEqual,
|
||||
buildSyncedAnalyticsTableSortRouteQuery,
|
||||
getRouteAnalyticsTableSortState,
|
||||
toAnalyticsTableSortState,
|
||||
} from './analytics-table-sort-route.ts'
|
||||
import { sortAnalyticsTableRows } from './analytics-table-sorting.ts'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types.ts'
|
||||
import { useAnalyticsTableGraphSelection } from './use-analytics-table-graph-selection.ts'
|
||||
import { useAnalyticsTablePagination } from './use-analytics-table-pagination.ts'
|
||||
import { useAnalyticsTableRowCache } from './use-analytics-table-row-cache.ts'
|
||||
|
||||
const {
|
||||
hasProjectContext,
|
||||
projects,
|
||||
selectedProjectIds: currentSelectedProjectIds,
|
||||
selectedBreakdowns: currentSelectedBreakdowns,
|
||||
displayedSelectedProjectIds: selectedProjectIds,
|
||||
displayedSelectedGroupBy: selectedGroupBy,
|
||||
displayedSelectedBreakdowns: selectedBreakdowns,
|
||||
displayedSelectedFilters: selectedFilters,
|
||||
displayedFetchRequest: fetchRequest,
|
||||
displayedTimeSlices: timeSlices,
|
||||
activeStat,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedGraphDatasetIds,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
isLoading,
|
||||
versionNumbersById,
|
||||
versionProjectNamesById,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const isDataLoading = computed(() => isLoading.value)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const initialTableSortState = readAnalyticsTableSortState(route.query, {
|
||||
sortColumn: 'date',
|
||||
sortDirection: 'desc',
|
||||
})
|
||||
|
||||
const tableMode = ref<AnalyticsTableMode>('breakdown_only')
|
||||
const sortColumn = ref<AnalyticsTableColumnKey | undefined>(initialTableSortState.sortColumn)
|
||||
const sortDirection = ref<AnalyticsTableSortDirectionValue>(initialTableSortState.sortDirection)
|
||||
const PAGE_SIZE = 500
|
||||
const GRAPH_DATASET_SELECTION_LIMIT = 8
|
||||
const INACTIVE_MODE_WARMUP_POINT_LIMIT = 12000
|
||||
const searchQuery = ref('')
|
||||
const sortCollator = new Intl.Collator(undefined, { sensitivity: 'base' })
|
||||
|
||||
const selectedProjectIdSet = computed(
|
||||
() =>
|
||||
new Set(
|
||||
projects.value
|
||||
.filter(
|
||||
(project) =>
|
||||
selectedProjectIds.value.includes(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
|
||||
)
|
||||
.map((project) => project.id),
|
||||
),
|
||||
)
|
||||
const selectedBreakdownSet = computed(() => new Set(selectedBreakdowns.value))
|
||||
const showBreakdownColumn = computed(() => selectedBreakdowns.value.length > 0)
|
||||
const showGraphDatasetSelection = computed(() =>
|
||||
selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'project'
|
||||
? selectedProjectIdSet.value.size > 1
|
||||
: selectedBreakdowns.value.length > 0,
|
||||
)
|
||||
const showProjectVersionProjectColumn = computed(
|
||||
() =>
|
||||
selectedBreakdownSet.value.has('version_id') &&
|
||||
!selectedBreakdownSet.value.has('project') &&
|
||||
selectedProjectIdSet.value.size > 1,
|
||||
)
|
||||
const includeDateColumn = computed(
|
||||
() =>
|
||||
selectedBreakdowns.value.length === 0 ||
|
||||
(!showGraphDatasetSelection.value && tableMode.value === 'date_breakdown'),
|
||||
)
|
||||
const activeTableMode = computed<AnalyticsTableMode>(() =>
|
||||
selectedBreakdowns.value.length === 0
|
||||
? 'date_breakdown'
|
||||
: showGraphDatasetSelection.value
|
||||
? 'breakdown_only'
|
||||
: tableMode.value,
|
||||
)
|
||||
const displayedIncludeDateColumn = computed(() =>
|
||||
selectedBreakdowns.value.length === 0
|
||||
? true
|
||||
: showGraphDatasetSelection.value
|
||||
? false
|
||||
: displayedTableMode.value === 'date_breakdown',
|
||||
)
|
||||
const groupByLabel = computed(() =>
|
||||
getAnalyticsTableGroupByLabel(selectedGroupBy.value, formatMessage),
|
||||
)
|
||||
const csvExportOptions = computed<OverflowMenuOption[]>(() => {
|
||||
if (showGraphDatasetSelection.value) {
|
||||
return [
|
||||
{
|
||||
id: 'cumulative-csv',
|
||||
action: () => downloadCsv('breakdown_only'),
|
||||
},
|
||||
{
|
||||
id: 'grouped-csv',
|
||||
action: () => downloadCsv('date_breakdown'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const mode = displayedTableMode.value
|
||||
|
||||
return [
|
||||
{
|
||||
id: mode === 'date_breakdown' ? 'grouped-csv' : 'cumulative-csv',
|
||||
action: () => downloadCsv(mode),
|
||||
},
|
||||
]
|
||||
})
|
||||
const projectNamesById = computed(
|
||||
() => new Map(projects.value.map((project) => [project.id, project.name])),
|
||||
)
|
||||
const hasAvailableProjects = computed(() => projects.value.length > 0)
|
||||
const analyticsPointCount = computed(() =>
|
||||
timeSlices.value.reduce((sum, slice) => sum + slice.length, 0),
|
||||
)
|
||||
const emptyTableMessage = computed(() => {
|
||||
if (trimmedSearchQuery.value && sortedRows.value.length > 0) {
|
||||
return formatMessage(analyticsTableMessages.noMatchingRows)
|
||||
}
|
||||
|
||||
if (hasProjectContext.value) {
|
||||
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
|
||||
}
|
||||
|
||||
return hasAvailableProjects.value
|
||||
? formatMessage(analyticsMessages.noDataAvailable)
|
||||
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
|
||||
})
|
||||
|
||||
const breakdownColumnLabel = computed(() =>
|
||||
selectedBreakdowns.value.length === 1
|
||||
? getAnalyticsTableBreakdownColumnLabel(selectedBreakdowns.value[0], formatMessage)
|
||||
: formatMessage(analyticsBreakdownMessages.breakdown),
|
||||
)
|
||||
const relevantStats = computed(
|
||||
() =>
|
||||
new Set(getRelevantAnalyticsDashboardStats(selectedBreakdowns.value, selectedFilters.value)),
|
||||
)
|
||||
|
||||
const showTimeInBucketLabel = computed(() => isTimeRelevantForGroupBy(selectedGroupBy.value))
|
||||
const showYearInBucketLabel = computed(() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
return nextFetchRequest
|
||||
? isYearRelevantForTimeRange(nextFetchRequest.time_range) || selectedGroupBy.value === 'year'
|
||||
: false
|
||||
})
|
||||
|
||||
function buildTableRows(mode: AnalyticsTableMode) {
|
||||
return buildAnalyticsTableRows({
|
||||
mode,
|
||||
fetchRequest: fetchRequest.value,
|
||||
timeSlices: timeSlices.value,
|
||||
selectedBreakdowns: selectedBreakdowns.value,
|
||||
selectedProjectIds: selectedProjectIdSet.value,
|
||||
relevantStats: relevantStats.value,
|
||||
projectNamesById: projectNamesById.value,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
showTimeInBucketLabel: showTimeInBucketLabel.value,
|
||||
showYearInBucketLabel: showYearInBucketLabel.value,
|
||||
formatMessage,
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => buildColumns(displayedIncludeDateColumn.value))
|
||||
const activeColumns = computed(() => buildColumns(includeDateColumn.value))
|
||||
|
||||
function buildColumns(includeDate: boolean) {
|
||||
return buildAnalyticsTableColumns({
|
||||
includeDate,
|
||||
selectedBreakdowns: selectedBreakdowns.value,
|
||||
selectedFilters: selectedFilters.value,
|
||||
showBreakdownColumn: showBreakdownColumn.value,
|
||||
showProjectVersionProjectColumn: showProjectVersionProjectColumn.value,
|
||||
formatMessage,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
activeColumns,
|
||||
(nextColumns) => {
|
||||
applyRouteOrDefaultSort(nextColumns)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function sortTableRows(rows: ReturnType<typeof buildTableRows>) {
|
||||
return sortAnalyticsTableRows(rows, sortColumn.value, sortDirection.value, sortCollator)
|
||||
}
|
||||
|
||||
const {
|
||||
displayedTableMode,
|
||||
displayedSortColumn,
|
||||
displayedSortDirection,
|
||||
displayedSortedRows,
|
||||
invalidateTableCaches,
|
||||
invalidateSortedCaches,
|
||||
scheduleRowsForMode,
|
||||
scheduleInactiveModeWarmup,
|
||||
resortDisplayedRowsForCurrentSort,
|
||||
getSortedRowsForMode,
|
||||
} = useAnalyticsTableRowCache({
|
||||
activeTableMode,
|
||||
showBreakdownColumn,
|
||||
analyticsPointCount,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
buildRows: buildTableRows,
|
||||
sortRows: sortTableRows,
|
||||
inactiveModeWarmupPointLimit: INACTIVE_MODE_WARMUP_POINT_LIMIT,
|
||||
})
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
return displayedSortedRows.value
|
||||
})
|
||||
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
|
||||
const searchableColumns = computed(() => getAnalyticsTableSearchableColumns(columns.value))
|
||||
const filteredRows = computed(() => {
|
||||
if (!trimmedSearchQuery.value) {
|
||||
return sortedRows.value
|
||||
}
|
||||
|
||||
return filterAnalyticsTableRowsBySearch(
|
||||
sortedRows.value,
|
||||
searchableColumns.value,
|
||||
trimmedSearchQuery.value,
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
fetchRequest,
|
||||
timeSlices,
|
||||
selectedProjectIds,
|
||||
selectedGroupBy,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
projects,
|
||||
versionNumbersById,
|
||||
versionProjectNamesById,
|
||||
],
|
||||
() => {
|
||||
invalidateTableCaches()
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
watch(activeTableMode, () => {
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
(nextQuery) => {
|
||||
const nextSortState = getRouteTableSortState(nextQuery, activeColumns.value)
|
||||
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
|
||||
applyTableSortState(nextSortState)
|
||||
return
|
||||
}
|
||||
|
||||
syncTableSortRouteQuery()
|
||||
},
|
||||
)
|
||||
|
||||
watch([sortColumn, sortDirection], () => {
|
||||
syncTableSortRouteQuery()
|
||||
|
||||
if (resortDisplayedRowsForCurrentSort()) {
|
||||
scheduleInactiveModeWarmup()
|
||||
return
|
||||
}
|
||||
|
||||
invalidateSortedCaches()
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
})
|
||||
|
||||
const { filteredSelectableGraphDatasetIds, tableSelectedGraphDatasetIds } =
|
||||
useAnalyticsTableGraphSelection({
|
||||
sortedRows,
|
||||
filteredRows,
|
||||
sortColumn,
|
||||
showGraphDatasetSelection,
|
||||
selectedGraphDatasetIds,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
currentSelectedBreakdowns,
|
||||
currentSelectedProjectIds,
|
||||
activeStat,
|
||||
sortCollator,
|
||||
hasTableSortQuery: () => hasAnalyticsTableSortQuery(route.query),
|
||||
applyActiveStatSort,
|
||||
graphDatasetSelectionLimit: GRAPH_DATASET_SELECTION_LIMIT,
|
||||
})
|
||||
|
||||
const { currentPage, pageCount, visibleRowStart, visibleRowEnd, paginatedRows, switchPage } =
|
||||
useAnalyticsTablePagination({
|
||||
filteredRows,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const revenueFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
function formatInteger(value: number): string {
|
||||
return formatAnalyticsTableInteger(formatNumber, value)
|
||||
}
|
||||
|
||||
function formatRevenue(value: number): string {
|
||||
return formatAnalyticsTableRevenue(revenueFormatter.value, value, formatMessage)
|
||||
}
|
||||
|
||||
function formatCompactPlaytime(value: number): string {
|
||||
return formatAnalyticsTableCompactPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function formatFullPlaytime(value: number): string {
|
||||
return formatAnalyticsTableFullPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function applyRouteOrDefaultSort(nextColumns = activeColumns.value) {
|
||||
const nextSortState = getRouteTableSortState(route.query, nextColumns)
|
||||
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
|
||||
applyTableSortState(nextSortState)
|
||||
}
|
||||
|
||||
syncTableSortRouteQuery()
|
||||
}
|
||||
|
||||
function applyTableSortState(state: {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
}) {
|
||||
sortColumn.value = state.sortColumn
|
||||
sortDirection.value = state.sortDirection
|
||||
}
|
||||
|
||||
function getRouteTableSortState(
|
||||
query: LocationQuery,
|
||||
nextColumns = activeColumns.value,
|
||||
): {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
} {
|
||||
return getRouteAnalyticsTableSortState(query, nextColumns, getDefaultSortOptions(nextColumns))
|
||||
}
|
||||
|
||||
function getCurrentSortState() {
|
||||
return toAnalyticsTableSortState(sortColumn.value, sortDirection.value)
|
||||
}
|
||||
|
||||
function getDefaultSortOptions(nextColumns = activeColumns.value) {
|
||||
return {
|
||||
columns: nextColumns,
|
||||
showGraphDatasetSelection: showGraphDatasetSelection.value,
|
||||
activeStat: activeStat.value,
|
||||
}
|
||||
}
|
||||
|
||||
function syncTableSortRouteQuery() {
|
||||
if (import.meta.server) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRouteQuery = buildSyncedAnalyticsTableSortRouteQuery(
|
||||
route.query,
|
||||
getCurrentSortState(),
|
||||
activeColumns.value,
|
||||
getDefaultSortOptions(),
|
||||
)
|
||||
|
||||
if (!hasAnalyticsTableSortRouteChange(route.query, nextRouteQuery)) {
|
||||
return
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextRouteQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function applyActiveStatSort() {
|
||||
const availableColumns = new Set(activeColumns.value.map((column) => column.key))
|
||||
if (!availableColumns.has(activeStat.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
sortColumn.value = activeStat.value
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
|
||||
function applyRequestedSort(column: string, direction: AnalyticsTableSortDirectionValue) {
|
||||
sortColumn.value = column as AnalyticsTableColumnKey
|
||||
sortDirection.value = direction
|
||||
}
|
||||
|
||||
function selectSearchInputText(event: FocusEvent) {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
target.select()
|
||||
}
|
||||
}
|
||||
|
||||
function getCsvRows(mode: AnalyticsTableMode) {
|
||||
const visibleColumns = getCsvColumns(mode)
|
||||
return filterAnalyticsTableRowsBySearch(
|
||||
getSortedRowsForMode(mode),
|
||||
visibleColumns,
|
||||
trimmedSearchQuery.value,
|
||||
)
|
||||
}
|
||||
|
||||
function getCsvColumns(mode: AnalyticsTableMode) {
|
||||
return buildColumns(mode === 'date_breakdown' || !showBreakdownColumn.value)
|
||||
}
|
||||
|
||||
function getCsvFilename(): string {
|
||||
return getAnalyticsTableCsvFilename(breakdownColumnLabel.value, fetchRequest.value, formatMessage)
|
||||
}
|
||||
|
||||
function downloadCsv(mode: AnalyticsTableMode) {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const csvRows = getCsvRows(mode)
|
||||
if (csvRows.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const visibleColumns = getCsvColumns(mode)
|
||||
const csvContent = buildAnalyticsTableCsvContent(csvRows, visibleColumns, formatMessage)
|
||||
downloadAnalyticsTableCsv(getCsvFilename(), csvContent)
|
||||
}
|
||||
</script>
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import type { ComputedRef, Ref, WritableComputedRef } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { areStringArraysEqual } from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type {
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { getAnalyticsTableMetricSortedGraphDatasetIds } from './analytics-table-sorting'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTableGraphSelectionOptions = {
|
||||
sortedRows: ComputedRef<AnalyticsTableRow[]>
|
||||
filteredRows: ComputedRef<AnalyticsTableRow[]>
|
||||
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
showGraphDatasetSelection: ComputedRef<boolean>
|
||||
selectedGraphDatasetIds: Ref<string[]>
|
||||
hasExplicitGraphDatasetSelection: Ref<boolean>
|
||||
isGraphDatasetSelectionActive: Ref<boolean>
|
||||
defaultGraphDatasetIds: Ref<string[]>
|
||||
topGraphDatasetIds: Ref<string[]>
|
||||
queryResetToken: Ref<number>
|
||||
currentSelectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
|
||||
currentSelectedProjectIds: Ref<string[]>
|
||||
activeStat: Ref<AnalyticsDashboardStat>
|
||||
sortCollator: Intl.Collator
|
||||
hasTableSortQuery: () => boolean
|
||||
applyActiveStatSort: () => void
|
||||
graphDatasetSelectionLimit: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTableGraphSelection({
|
||||
sortedRows,
|
||||
filteredRows,
|
||||
sortColumn,
|
||||
showGraphDatasetSelection,
|
||||
selectedGraphDatasetIds,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
currentSelectedBreakdowns,
|
||||
currentSelectedProjectIds,
|
||||
activeStat,
|
||||
sortCollator,
|
||||
hasTableSortQuery,
|
||||
applyActiveStatSort,
|
||||
graphDatasetSelectionLimit,
|
||||
}: UseAnalyticsTableGraphSelectionOptions): {
|
||||
filteredSelectableGraphDatasetIds: ComputedRef<string[]>
|
||||
tableSelectedGraphDatasetIds: WritableComputedRef<unknown[]>
|
||||
} {
|
||||
const selectableGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableSelectableGraphDatasetIds(sortedRows.value),
|
||||
)
|
||||
const filteredSelectableGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableSelectableGraphDatasetIds(filteredRows.value),
|
||||
)
|
||||
const sortedMetricGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableMetricSortedGraphDatasetIds(sortedRows.value, sortColumn.value, sortCollator),
|
||||
)
|
||||
const defaultSelectedGraphDatasetIds = computed(() => {
|
||||
const sortedMetricIds = sortedMetricGraphDatasetIds.value
|
||||
const defaultIds =
|
||||
sortedMetricIds.length > 0 ? sortedMetricIds : selectableGraphDatasetIds.value
|
||||
return defaultIds.slice(0, graphDatasetSelectionLimit)
|
||||
})
|
||||
const tableSelectedGraphDatasetIds = computed<unknown[]>({
|
||||
get: () => selectedGraphDatasetIds.value,
|
||||
set: (ids) => {
|
||||
const nextGraphDatasetIds = ids.filter((id): id is string => typeof id === 'string')
|
||||
if (showGraphDatasetSelection.value && isDefaultGraphDatasetSelection(nextGraphDatasetIds)) {
|
||||
setSelectedGraphDatasetIds(defaultSelectedGraphDatasetIds.value, false)
|
||||
return
|
||||
}
|
||||
|
||||
selectedGraphDatasetIds.value = nextGraphDatasetIds
|
||||
hasExplicitGraphDatasetSelection.value = showGraphDatasetSelection.value
|
||||
},
|
||||
})
|
||||
|
||||
function setSelectedGraphDatasetIds(ids: string[], explicit: boolean) {
|
||||
selectedGraphDatasetIds.value = ids
|
||||
hasExplicitGraphDatasetSelection.value = explicit
|
||||
}
|
||||
|
||||
function resetGraphDatasetSelection() {
|
||||
setSelectedGraphDatasetIds([], false)
|
||||
}
|
||||
|
||||
function isDefaultGraphDatasetSelection(ids: string[]) {
|
||||
const defaultIds = defaultSelectedGraphDatasetIds.value
|
||||
if (defaultIds.length === 0 || ids.length !== defaultIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedIdSet = new Set(ids)
|
||||
return defaultIds.every((id) => selectedIdSet.has(id))
|
||||
}
|
||||
|
||||
watch(
|
||||
[showGraphDatasetSelection, queryResetToken],
|
||||
([nextShowSelection]) => {
|
||||
isGraphDatasetSelectionActive.value = nextShowSelection
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(activeStat, () => {
|
||||
if (!showGraphDatasetSelection.value) {
|
||||
return
|
||||
}
|
||||
if (hasTableSortQuery()) {
|
||||
return
|
||||
}
|
||||
|
||||
applyActiveStatSort()
|
||||
})
|
||||
|
||||
watch(
|
||||
currentSelectedBreakdowns,
|
||||
(nextBreakdowns, previousBreakdowns) => {
|
||||
if (areStringArraysEqual([...nextBreakdowns], [...(previousBreakdowns ?? [])])) {
|
||||
return
|
||||
}
|
||||
|
||||
resetGraphDatasetSelection()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
currentSelectedProjectIds,
|
||||
(nextProjectIds, previousProjectIds) => {
|
||||
if (areStringArraysEqual(nextProjectIds, previousProjectIds ?? [])) {
|
||||
return
|
||||
}
|
||||
|
||||
resetGraphDatasetSelection()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[defaultSelectedGraphDatasetIds, sortedMetricGraphDatasetIds, showGraphDatasetSelection],
|
||||
([nextDefaultGraphDatasetIds, nextTopGraphDatasetIds, nextShowGraphDatasetSelection]) => {
|
||||
defaultGraphDatasetIds.value = nextShowGraphDatasetSelection
|
||||
? [...nextDefaultGraphDatasetIds]
|
||||
: []
|
||||
topGraphDatasetIds.value = nextShowGraphDatasetSelection ? [...nextTopGraphDatasetIds] : []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
defaultSelectedGraphDatasetIds,
|
||||
showGraphDatasetSelection,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
queryResetToken,
|
||||
],
|
||||
([nextDefaultGraphDatasetIds, nextShowGraphDatasetSelection, nextHasExplicitSelection]) => {
|
||||
if (!nextShowGraphDatasetSelection) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextHasExplicitSelection) {
|
||||
if (isDefaultGraphDatasetSelection(selectedGraphDatasetIds.value)) {
|
||||
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!areStringArraysEqual(selectedGraphDatasetIds.value, nextDefaultGraphDatasetIds)) {
|
||||
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getAnalyticsTableSelectableGraphDatasetIds(rows: AnalyticsTableRow[]): string[] {
|
||||
return Array.from(new Set(rows.map((row) => row.graphDatasetId)))
|
||||
}
|
||||
|
||||
return {
|
||||
filteredSelectableGraphDatasetIds,
|
||||
tableSelectedGraphDatasetIds,
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTablePaginationOptions = {
|
||||
filteredRows: ComputedRef<AnalyticsTableRow[]>
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTablePagination({
|
||||
filteredRows,
|
||||
pageSize,
|
||||
}: UseAnalyticsTablePaginationOptions): {
|
||||
currentPage: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
visibleRowStart: ComputedRef<number>
|
||||
visibleRowEnd: ComputedRef<number>
|
||||
paginatedRows: ComputedRef<AnalyticsTableRow[]>
|
||||
switchPage: (page: number) => void
|
||||
} {
|
||||
const currentPage = ref(1)
|
||||
const pageCount = computed(() => Math.max(Math.ceil(filteredRows.value.length / pageSize), 1))
|
||||
const visibleRowStart = computed(() =>
|
||||
filteredRows.value.length === 0 ? 0 : (currentPage.value - 1) * pageSize + 1,
|
||||
)
|
||||
const visibleRowEnd = computed(() =>
|
||||
Math.min(currentPage.value * pageSize, filteredRows.value.length),
|
||||
)
|
||||
const paginatedRows = computed<AnalyticsTableRow[]>(() =>
|
||||
filteredRows.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize),
|
||||
)
|
||||
|
||||
watch(filteredRows, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch(pageCount, (nextPageCount) => {
|
||||
if (currentPage.value > nextPageCount) {
|
||||
currentPage.value = nextPageCount
|
||||
}
|
||||
})
|
||||
|
||||
function switchPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageCount,
|
||||
visibleRowStart,
|
||||
visibleRowEnd,
|
||||
paginatedRows,
|
||||
switchPage,
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableDisplayedRowsCache,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableRow,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTableRowCacheOptions = {
|
||||
activeTableMode: ComputedRef<AnalyticsTableMode>
|
||||
showBreakdownColumn: ComputedRef<boolean>
|
||||
analyticsPointCount: ComputedRef<number>
|
||||
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
sortDirection: Ref<AnalyticsTableSortDirectionValue>
|
||||
buildRows: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
|
||||
sortRows: (rows: AnalyticsTableRow[]) => AnalyticsTableRow[]
|
||||
inactiveModeWarmupPointLimit: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTableRowCache({
|
||||
activeTableMode,
|
||||
showBreakdownColumn,
|
||||
analyticsPointCount,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
buildRows,
|
||||
sortRows,
|
||||
inactiveModeWarmupPointLimit,
|
||||
}: UseAnalyticsTableRowCacheOptions): {
|
||||
displayedTableMode: Ref<AnalyticsTableMode>
|
||||
displayedSortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
displayedSortDirection: Ref<AnalyticsTableSortDirectionValue>
|
||||
displayedSortedRows: ShallowRef<AnalyticsTableRow[]>
|
||||
invalidateTableCaches: () => void
|
||||
invalidateSortedCaches: () => void
|
||||
scheduleRowsForMode: (mode: AnalyticsTableMode) => void
|
||||
scheduleInactiveModeWarmup: () => void
|
||||
resortDisplayedRowsForCurrentSort: () => boolean
|
||||
getSortedRowsForMode: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
|
||||
} {
|
||||
const modeBuildRequestIds: Record<AnalyticsTableMode, number> = {
|
||||
date_breakdown: 0,
|
||||
breakdown_only: 0,
|
||||
}
|
||||
let tableCacheGeneration = 0
|
||||
let displayedSortedRowsGeneration = 0
|
||||
const displayedTableMode = ref<AnalyticsTableMode>('breakdown_only')
|
||||
const displayedSortColumn = ref<AnalyticsTableColumnKey | undefined>(sortColumn.value)
|
||||
const displayedSortDirection = ref<AnalyticsTableSortDirectionValue>(sortDirection.value)
|
||||
const displayedSortedRows = shallowRef<AnalyticsTableRow[]>([])
|
||||
const displayedRowsCache = shallowRef<AnalyticsTableDisplayedRowsCache | null>(null)
|
||||
|
||||
function invalidateTableCaches() {
|
||||
tableCacheGeneration++
|
||||
invalidateSortedCaches()
|
||||
}
|
||||
|
||||
function invalidateSortedCaches() {
|
||||
displayedRowsCache.value = null
|
||||
}
|
||||
|
||||
function hasSortedRowsForMode(mode: AnalyticsTableMode): boolean {
|
||||
const cached = displayedRowsCache.value
|
||||
return (
|
||||
cached !== null &&
|
||||
cached.generation === tableCacheGeneration &&
|
||||
cached.mode === mode &&
|
||||
cached.sortColumn === sortColumn.value &&
|
||||
cached.sortDirection === sortDirection.value
|
||||
)
|
||||
}
|
||||
|
||||
function setDisplayedRowsForMode(
|
||||
mode: AnalyticsTableMode,
|
||||
rows: AnalyticsTableRow[],
|
||||
generation = tableCacheGeneration,
|
||||
) {
|
||||
displayedRowsCache.value = {
|
||||
generation,
|
||||
mode,
|
||||
sortColumn: sortColumn.value,
|
||||
sortDirection: sortDirection.value,
|
||||
rows,
|
||||
}
|
||||
|
||||
if (mode === activeTableMode.value) {
|
||||
displayedSortedRowsGeneration = generation
|
||||
displayedTableMode.value = mode
|
||||
displayedSortColumn.value = sortColumn.value
|
||||
displayedSortDirection.value = sortDirection.value
|
||||
displayedSortedRows.value = rows
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRowsForMode(mode: AnalyticsTableMode) {
|
||||
if (hasSortedRowsForMode(mode)) {
|
||||
if (mode === activeTableMode.value) {
|
||||
displayRowsForMode(mode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++modeBuildRequestIds[mode]
|
||||
const generation = tableCacheGeneration
|
||||
|
||||
void buildRowsForMode(mode, generation, requestId)
|
||||
}
|
||||
|
||||
function displayRowsForMode(mode: AnalyticsTableMode) {
|
||||
const cached = displayedRowsCache.value
|
||||
if (!cached || cached.generation !== tableCacheGeneration || cached.mode !== mode) {
|
||||
return
|
||||
}
|
||||
|
||||
displayedSortedRowsGeneration = cached.generation
|
||||
displayedTableMode.value = mode
|
||||
displayedSortColumn.value = cached.sortColumn
|
||||
displayedSortDirection.value = cached.sortDirection
|
||||
displayedSortedRows.value = cached.rows
|
||||
}
|
||||
|
||||
async function buildRowsForMode(mode: AnalyticsTableMode, generation: number, requestId: number) {
|
||||
await waitForDeferredTableWork()
|
||||
|
||||
if (isStaleBuild(mode, generation, requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = sortRows(buildRows(mode))
|
||||
|
||||
if (isStaleBuild(mode, generation, requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisplayedRowsForMode(mode, rows, generation)
|
||||
}
|
||||
|
||||
function isStaleBuild(mode: AnalyticsTableMode, generation: number, requestId: number): boolean {
|
||||
return tableCacheGeneration !== generation || modeBuildRequestIds[mode] !== requestId
|
||||
}
|
||||
|
||||
function waitForDeferredTableWork(): Promise<void> {
|
||||
if (!import.meta.client) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleInactiveModeWarmup() {
|
||||
if (!showBreakdownColumn.value) {
|
||||
return
|
||||
}
|
||||
if (analyticsPointCount.value > inactiveModeWarmupPointLimit) {
|
||||
return
|
||||
}
|
||||
|
||||
const inactiveMode: AnalyticsTableMode =
|
||||
activeTableMode.value === 'date_breakdown' ? 'breakdown_only' : 'date_breakdown'
|
||||
|
||||
if (hasSortedRowsForMode(inactiveMode)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!import.meta.client) {
|
||||
scheduleRowsForMode(inactiveMode)
|
||||
return
|
||||
}
|
||||
|
||||
const windowWithIdleCallback = window as Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number
|
||||
}
|
||||
|
||||
if (windowWithIdleCallback.requestIdleCallback) {
|
||||
windowWithIdleCallback.requestIdleCallback(() => scheduleRowsForMode(inactiveMode), {
|
||||
timeout: 2000,
|
||||
})
|
||||
} else {
|
||||
window.setTimeout(() => scheduleRowsForMode(inactiveMode), 250)
|
||||
}
|
||||
}
|
||||
|
||||
function resortDisplayedRowsForCurrentSort(): boolean {
|
||||
const mode = activeTableMode.value
|
||||
if (
|
||||
displayedTableMode.value !== mode ||
|
||||
displayedSortedRowsGeneration !== tableCacheGeneration
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
setDisplayedRowsForMode(mode, sortRows(displayedSortedRows.value))
|
||||
return true
|
||||
}
|
||||
|
||||
function getSortedRowsForMode(mode: AnalyticsTableMode): AnalyticsTableRow[] {
|
||||
const cached = displayedRowsCache.value
|
||||
if (
|
||||
cached &&
|
||||
cached.generation === tableCacheGeneration &&
|
||||
cached.mode === mode &&
|
||||
cached.sortColumn === sortColumn.value &&
|
||||
cached.sortDirection === sortDirection.value
|
||||
) {
|
||||
return cached.rows
|
||||
}
|
||||
|
||||
return sortRows(buildRows(mode))
|
||||
}
|
||||
|
||||
return {
|
||||
displayedTableMode,
|
||||
displayedSortColumn,
|
||||
displayedSortDirection,
|
||||
displayedSortedRows,
|
||||
invalidateTableCaches,
|
||||
invalidateSortedCaches,
|
||||
scheduleRowsForMode,
|
||||
scheduleInactiveModeWarmup,
|
||||
resortDisplayedRowsForCurrentSort,
|
||||
getSortedRowsForMode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import { formatAnalyticsDownloadSourceLabel, type FormatMessage } from './analytics-messages'
|
||||
|
||||
export const ALL_BREAKDOWN_VALUE = '__all__'
|
||||
export const UNKNOWN_BREAKDOWN_VALUE = '__unknown__'
|
||||
export const COMBINED_BREAKDOWN_LABEL_SEPARATOR = ' + '
|
||||
export const COMBINED_BREAKDOWN_DATASET_ID_PREFIX = 'breakdowns:'
|
||||
|
||||
export function getAnalyticsBreakdownValue(
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
selectedBreakdown: AnalyticsBreakdownPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (selectedBreakdown) {
|
||||
case 'none':
|
||||
return ALL_BREAKDOWN_VALUE
|
||||
case 'project':
|
||||
return normalizeBreakdownValue('source_project' in point ? point.source_project : undefined)
|
||||
case 'country':
|
||||
return normalizeBreakdownValue('country' in point ? point.country?.toUpperCase() : undefined)
|
||||
case 'monetization': {
|
||||
if ('monetized' in point && typeof point.monetized === 'boolean') {
|
||||
return point.monetized ? 'monetized' : 'unmonetized'
|
||||
}
|
||||
return ALL_BREAKDOWN_VALUE
|
||||
}
|
||||
case 'user_agent': {
|
||||
const downloadSource = normalizeBreakdownValue(
|
||||
'user_agent' in point ? point.user_agent : undefined,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
)
|
||||
return downloadSource === UNKNOWN_BREAKDOWN_VALUE
|
||||
? UNKNOWN_BREAKDOWN_VALUE
|
||||
: getDownloadSourceLabel(downloadSource, formatMessage)
|
||||
}
|
||||
case 'download_reason':
|
||||
return normalizeBreakdownValue(
|
||||
'reason' in point ? point.reason : undefined,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
)
|
||||
case 'version_id':
|
||||
return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined)
|
||||
case 'loader':
|
||||
return normalizeBreakdownValue(
|
||||
'loader' in point ? point.loader : undefined,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
)
|
||||
case 'game_version':
|
||||
return normalizeBreakdownValue(
|
||||
'game_version' in point ? point.game_version : undefined,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
)
|
||||
default:
|
||||
return ALL_BREAKDOWN_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownValues(
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
formatMessage: FormatMessage,
|
||||
): string[] {
|
||||
return selectedBreakdowns
|
||||
.filter((breakdown) => breakdown !== 'none')
|
||||
.map((breakdown) => getAnalyticsBreakdownValue(point, breakdown, formatMessage))
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownKey(values: readonly string[]): string {
|
||||
return values.map((value) => encodeURIComponent(value)).join('+')
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownDatasetId(
|
||||
values: readonly string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
): string {
|
||||
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
|
||||
if (normalizedBreakdowns.length === 0) {
|
||||
return 'all'
|
||||
}
|
||||
if (normalizedBreakdowns.length === 1) {
|
||||
if (normalizedBreakdowns[0] === 'project') {
|
||||
return values[0] ?? 'all'
|
||||
}
|
||||
return `breakdown:${values[0] ?? 'all'}`
|
||||
}
|
||||
|
||||
return `${COMBINED_BREAKDOWN_DATASET_ID_PREFIX}${getAnalyticsBreakdownKey(values)}`
|
||||
}
|
||||
|
||||
export function getDownloadSourceLabel(value: string, formatMessage: FormatMessage): string {
|
||||
return formatAnalyticsDownloadSourceLabel(value, formatMessage)
|
||||
}
|
||||
|
||||
function normalizeBreakdownValue(
|
||||
value: string | undefined,
|
||||
fallback = ALL_BREAKDOWN_VALUE,
|
||||
): string {
|
||||
const normalized = value?.trim()
|
||||
const normalizedLowercase = normalized?.toLowerCase()
|
||||
if (
|
||||
fallback === UNKNOWN_BREAKDOWN_VALUE &&
|
||||
(normalizedLowercase === 'unknown' || normalizedLowercase === 'other')
|
||||
) {
|
||||
return fallback
|
||||
}
|
||||
return normalized && normalized.length > 0 ? normalized : fallback
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="flex touch-manipulation flex-col gap-4 pb-20 lg:pl-4 lg:pt-1.5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xl font-semibold text-contrast md:text-2xl">
|
||||
{{ formatMessage(analyticsMessages.title) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isAnalyticsQueryBuilderDefault"
|
||||
@click="resetAnalyticsQueryBuilder"
|
||||
>
|
||||
{{ formatMessage(analyticsMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="projects.length === 0 || !fetchRequest || isRefetching"
|
||||
@click="refreshAnalyticsQuery"
|
||||
>
|
||||
<RefreshCwIcon :class="isRefetching ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(analyticsMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilder />
|
||||
</div>
|
||||
<StatCards />
|
||||
<AnalyticsChart />
|
||||
<AnalyticsTable />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectProjectPageContext, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
createAnalyticsDashboardContext,
|
||||
provideAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
import { injectOrganizationContext } from '~/providers/organization-context'
|
||||
|
||||
import AnalyticsChart from './analytics-chart/index.vue'
|
||||
import { analyticsMessages } from './analytics-messages.ts'
|
||||
import AnalyticsTable from './analytics-table/index.vue'
|
||||
import QueryBuilder from './query-builder/index.vue'
|
||||
import StatCards from './stat-cards/StatCards.vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
const { formatMessage } = useVIntl()
|
||||
const projectPageContext = injectProjectPageContext(null)
|
||||
const organizationContext = injectOrganizationContext(null)
|
||||
|
||||
const analyticsDashboardContext = createAnalyticsDashboardContext({
|
||||
auth,
|
||||
projectPageContext,
|
||||
organizationContext,
|
||||
})
|
||||
const {
|
||||
fetchRequest,
|
||||
isAnalyticsQueryBuilderDefault,
|
||||
isRefetching,
|
||||
projects,
|
||||
refreshAnalyticsQuery,
|
||||
resetAnalyticsQueryBuilder,
|
||||
} = analyticsDashboardContext
|
||||
|
||||
provideAnalyticsDashboardContext(analyticsDashboardContext)
|
||||
</script>
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="shrink-0 whitespace-nowrap text-sm font-semibold text-primary">
|
||||
{{ props.label }}
|
||||
</span>
|
||||
<input
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
class="h-8 rounded-lg border border-solid border-surface-5 bg-surface-3 px-2 text-center text-sm font-semibold text-primary outline-none transition-[box-shadow,color] focus:text-contrast focus:ring-4 focus:ring-brand-shadow max-sm:text-base"
|
||||
:class="props.inputWidthClass"
|
||||
:aria-label="props.inputAriaLabel"
|
||||
@blur="formatInput"
|
||||
@keydown.enter.prevent.stop="submitInput"
|
||||
/>
|
||||
<span class="shrink-0 text-sm font-semibold text-primary">{{ suffixLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsMessages } from '../analytics-messages'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
inputAriaLabel: string
|
||||
threshold?: number | null
|
||||
suffix?: string
|
||||
inputWidthClass?: string
|
||||
}>(),
|
||||
{
|
||||
inputWidthClass: 'w-20',
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
'update:threshold': [threshold: number | null]
|
||||
submit: [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const suffixLabel = computed(() => props.suffix ?? formatMessage(analyticsMessages.downloadsSuffix))
|
||||
const inputValue = ref('')
|
||||
let isSyncingThreshold = false
|
||||
let hasPendingEmittedThreshold = false
|
||||
let pendingEmittedThreshold: number | null = null
|
||||
|
||||
function parseDownloadsThreshold(value: string): number | null | undefined {
|
||||
const normalizedValue = value.trim().toLowerCase().replace(/,/g, '')
|
||||
if (!normalizedValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = normalizedValue.match(/^(\d+(?:\.\d+)?)([kmb])?$/)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const amount = Number.parseFloat(match[1])
|
||||
if (!Number.isFinite(amount)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const multiplierBySuffix: Record<string, number> = {
|
||||
k: 1_000,
|
||||
m: 1_000_000,
|
||||
b: 1_000_000_000,
|
||||
}
|
||||
|
||||
const multiplier = match[2] ? multiplierBySuffix[match[2]] : 1
|
||||
return Math.max(0, Math.floor(amount * multiplier))
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number): string {
|
||||
const formatWithSuffix = (divisor: number, suffix: string) => {
|
||||
const dividedValue = value / divisor
|
||||
const fractionDigits = Number.isInteger(dividedValue) ? 0 : 1
|
||||
return `${dividedValue.toFixed(fractionDigits).replace(/\.0$/, '')}${suffix}`
|
||||
}
|
||||
|
||||
if (value >= 1_000_000_000) return formatWithSuffix(1_000_000_000, 'B')
|
||||
if (value >= 1_000_000) return formatWithSuffix(1_000_000, 'M')
|
||||
if (value >= 1_000) return formatWithSuffix(1_000, 'k')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatInput() {
|
||||
const threshold = parseDownloadsThreshold(inputValue.value)
|
||||
if (threshold === undefined || threshold === null) {
|
||||
return
|
||||
}
|
||||
|
||||
inputValue.value = formatCompactNumber(threshold)
|
||||
}
|
||||
|
||||
function submitInput(event: KeyboardEvent) {
|
||||
const threshold = parseDownloadsThreshold(inputValue.value)
|
||||
if (threshold === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (threshold !== null) {
|
||||
inputValue.value = formatCompactNumber(threshold)
|
||||
}
|
||||
|
||||
emit('update:threshold', threshold)
|
||||
emit('submit', event)
|
||||
}
|
||||
|
||||
watch(inputValue, (value) => {
|
||||
if (isSyncingThreshold) {
|
||||
return
|
||||
}
|
||||
|
||||
const threshold = parseDownloadsThreshold(value)
|
||||
if (threshold === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
hasPendingEmittedThreshold = true
|
||||
pendingEmittedThreshold = threshold
|
||||
emit('update:threshold', threshold)
|
||||
nextTick(() => {
|
||||
if (hasPendingEmittedThreshold && pendingEmittedThreshold === threshold) {
|
||||
hasPendingEmittedThreshold = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.threshold,
|
||||
(threshold) => {
|
||||
if (hasPendingEmittedThreshold && threshold === pendingEmittedThreshold) {
|
||||
hasPendingEmittedThreshold = false
|
||||
return
|
||||
}
|
||||
|
||||
isSyncingThreshold = true
|
||||
inputValue.value =
|
||||
threshold === null || threshold === undefined ? '' : formatCompactNumber(threshold)
|
||||
nextTick(() => {
|
||||
isSyncingThreshold = false
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,959 @@
|
||||
<template>
|
||||
<DropdownFilterBar
|
||||
v-model="selectedFilterValue"
|
||||
:categories="filterCategories"
|
||||
:show-clear="showClearAction && canClearSelectedBreakdown"
|
||||
:show-label="showLabel"
|
||||
:show-preview-filter-icon="showPreviewFilterIcon"
|
||||
:preview-trigger-class="previewTriggerClass"
|
||||
:add-button-class="addButtonClass"
|
||||
:clear-label="formatMessage(analyticsMessages.resetButton)"
|
||||
:add-label="resolvedAddLabel"
|
||||
checkbox-position="right"
|
||||
@clear="clearFilterBar"
|
||||
>
|
||||
<template #search-actions="{ category, setSelectedValues }">
|
||||
<div v-if="category.key === 'game_version'" class="mr-2 flex min-w-[124px] justify-end">
|
||||
<Tabs
|
||||
:value="gameVersionType"
|
||||
:tabs="gameVersionTypeTabs"
|
||||
:aria-label="formatMessage(analyticsMessages.gameVersionTypeAria)"
|
||||
@update:value="(type) => setGameVersionType(type, setSelectedValues)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #option="{ category, option, selected }">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<template v-if="category.key === 'version_id'">
|
||||
<span
|
||||
v-for="metadata in getProjectVersionOptionProjectMetadata(option.value)"
|
||||
:key="`${option.value}-${metadata.name}`"
|
||||
v-tooltip="metadata.name"
|
||||
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
|
||||
>
|
||||
<img
|
||||
v-if="metadata.iconUrl"
|
||||
:src="metadata.iconUrl"
|
||||
:alt="formatMessage(analyticsMessages.projectIconAlt, { name: metadata.name })"
|
||||
class="h-6 w-6 rounded object-cover"
|
||||
/>
|
||||
<BoxIcon v-else class="h-full w-full" />
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold leading-tight"
|
||||
:class="selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #category-footer="{ category, setSelectedValues, closeMenu }">
|
||||
<DownloadsThresholdInput
|
||||
v-if="category.key === 'country'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.countriesAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.countryDownloadsThresholdAria)"
|
||||
:threshold="countryDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setCountryDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyCountryDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<DownloadsThresholdInput
|
||||
v-else-if="category.key === 'version_id'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.projectVersionsAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.projectVersionDownloadsThresholdAria)"
|
||||
:threshold="projectVersionDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setProjectVersionDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyProjectVersionDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<DownloadsThresholdInput
|
||||
v-else-if="category.key === 'game_version'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.gameVersionsAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.gameVersionDownloadsThresholdAria)"
|
||||
:threshold="gameVersionDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setGameVersionDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyGameVersionDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #preview-footer="{ category, setSelectedValues, closeMenu }">
|
||||
<DownloadsThresholdInput
|
||||
v-if="category.key === 'country'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.countriesAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.countryDownloadsThresholdAria)"
|
||||
:threshold="countryDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setCountryDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyCountryDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<DownloadsThresholdInput
|
||||
v-else-if="category.key === 'version_id'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.projectVersionsAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.projectVersionDownloadsThresholdAria)"
|
||||
:threshold="projectVersionDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setProjectVersionDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyProjectVersionDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<DownloadsThresholdInput
|
||||
v-else-if="category.key === 'game_version'"
|
||||
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
|
||||
:label="formatMessage(analyticsMessages.gameVersionsAbove)"
|
||||
:input-aria-label="formatMessage(analyticsMessages.gameVersionDownloadsThresholdAria)"
|
||||
:threshold="gameVersionDownloadsThreshold"
|
||||
input-width-class="w-16"
|
||||
@update:threshold="
|
||||
(threshold) => setGameVersionDownloadsThreshold(threshold, setSelectedValues)
|
||||
"
|
||||
@submit="
|
||||
(event) =>
|
||||
runDownloadsThresholdQuery(
|
||||
applyGameVersionDownloadsThreshold,
|
||||
setSelectedValues,
|
||||
closeMenu,
|
||||
event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</DropdownFilterBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BoxIcon } from '@modrinth/assets'
|
||||
import {
|
||||
DropdownFilterBar,
|
||||
type DropdownFilterBarCategory,
|
||||
type DropdownFilterBarOption,
|
||||
Tabs,
|
||||
type TabsTab,
|
||||
type TabsValue,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import { useFormattedCountries } from '@/composables/country.ts'
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
getDefaultAnalyticsBreakdownPresets,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import {
|
||||
type AnalyticsQueryFilterCategory,
|
||||
type AnalyticsSelectedFilters,
|
||||
doesProjectStatusMatchFilters,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsBreakdownMessages,
|
||||
analyticsMessages,
|
||||
analyticsMonetizationMessages,
|
||||
formatAnalyticsDownloadReasonLabel,
|
||||
formatAnalyticsLoaderLabel,
|
||||
formatAnalyticsProjectStatusLabel,
|
||||
} from '../analytics-messages.ts'
|
||||
import { getDownloadSourceLabel } from '../breakdown.ts'
|
||||
import DownloadsThresholdInput from './DownloadsThresholdInput.vue'
|
||||
import {
|
||||
areSelectedFiltersEqual,
|
||||
buildProjectVersionFilterOptionProjectMetadataById,
|
||||
buildProjectVersionFilterOptions,
|
||||
cloneSelectedFilters,
|
||||
FILTER_VALUE_CATEGORIES,
|
||||
getOptionsWithSelectedValues,
|
||||
getProjectVersionFilterOptionMetadataIds,
|
||||
getProjectVersionFilterOptionProjectMetadataCacheKey,
|
||||
getProjectVersionFilterOptionsCacheKey,
|
||||
getVisibleAnalyticsFilterCategoriesForState,
|
||||
normalizeSelectedValues as normalizeSelectedFilterValues,
|
||||
type ProjectVersionFilterOption,
|
||||
type ProjectVersionFilterOptionProjectMetadata,
|
||||
} from './query-filter-utils.ts'
|
||||
|
||||
type AnalyticsFilterValueCategory = Exclude<AnalyticsQueryFilterCategory, 'project'>
|
||||
type GameVersionType = 'release' | 'all'
|
||||
type SetDropdownFilterValues = (values: string[]) => void
|
||||
type DownloadsThresholdSelection = {
|
||||
categoryKey: DownloadsThresholdFilterCategory
|
||||
selectedValues: string[]
|
||||
}
|
||||
type ApplyDownloadsThreshold = (
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
) => DownloadsThresholdSelection | null
|
||||
type CloseDownloadsThresholdMenu = (event?: Event) => void
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
addLabel?: string
|
||||
showLabel?: boolean
|
||||
showPreviewFilterIcon?: boolean
|
||||
previewTriggerClass?: string
|
||||
addButtonClass?: string
|
||||
showClearAction?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLabel: true,
|
||||
showPreviewFilterIcon: false,
|
||||
showClearAction: true,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
hasProjectContext,
|
||||
projects,
|
||||
selectedProjectIds,
|
||||
availableProjectStatuses,
|
||||
filterOptions,
|
||||
projectVersionDownloadsById,
|
||||
gameVersionDownloadsByVersion,
|
||||
countryDownloadsByCode,
|
||||
isAnalyticsFilterOptionsLoading,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
queryResetToken,
|
||||
refreshAnalyticsQuery,
|
||||
hasCompletedAnalyticsLoading,
|
||||
versionNumbersById,
|
||||
versionPublishedDatesById,
|
||||
versionProjectNamesById,
|
||||
versionProjectIconUrlsById,
|
||||
getVersionDisplayName,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formattedCountries = useFormattedCountries()
|
||||
const generatedState = useGeneratedState()
|
||||
|
||||
const gameVersionType = ref<GameVersionType>('release')
|
||||
const countryDownloadsThreshold = ref<number | null>(null)
|
||||
const projectVersionDownloadsThreshold = ref<number | null>(null)
|
||||
const gameVersionDownloadsThreshold = ref<number | null>(null)
|
||||
const gameVersionTypeTabs = computed<TabsTab[]>(() => [
|
||||
{ value: 'release', label: formatMessage(analyticsMessages.releaseTab) },
|
||||
{ value: 'all', label: formatMessage(analyticsMessages.allTab) },
|
||||
])
|
||||
const resolvedAddLabel = computed(
|
||||
() => props.addLabel ?? formatMessage(analyticsMessages.addButton),
|
||||
)
|
||||
const filterValueCategoryKeys = new Set<string>(FILTER_VALUE_CATEGORIES)
|
||||
const downloadsThresholdFilterCategories = ['country', 'version_id', 'game_version'] as const
|
||||
type DownloadsThresholdFilterCategory = (typeof downloadsThresholdFilterCategories)[number]
|
||||
const downloadsThresholdSelections = ref<
|
||||
Partial<Record<DownloadsThresholdFilterCategory, string[]>>
|
||||
>({})
|
||||
const projectStatusFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
availableProjectStatuses.value.map((status) => ({
|
||||
value: status,
|
||||
label: getProjectStatusFilterOptionLabel(status),
|
||||
})),
|
||||
)
|
||||
const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value))
|
||||
const effectiveSelectedProjectCount = computed(
|
||||
() =>
|
||||
projects.value.filter(
|
||||
(project) =>
|
||||
selectedProjectIdSet.value.has(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
|
||||
).length,
|
||||
)
|
||||
const showProjectVersionProjectIcons = computed(() => effectiveSelectedProjectCount.value > 1)
|
||||
const defaultSelectedBreakdown = computed(() =>
|
||||
getDefaultAnalyticsBreakdownPresets(selectedProjectIds.value),
|
||||
)
|
||||
const canClearSelectedBreakdown = computed(
|
||||
() => !areStringArraysEqual(selectedBreakdowns.value, defaultSelectedBreakdown.value),
|
||||
)
|
||||
const analyticsFilterOptionsEmptyLabel = computed(() =>
|
||||
isAnalyticsFilterOptionsLoading.value
|
||||
? formatMessage(analyticsMessages.loadingOptions)
|
||||
: undefined,
|
||||
)
|
||||
const projectVersionFilterOptions = shallowRef<ProjectVersionFilterOption[]>([])
|
||||
const projectVersionFilterOptionProjectMetadataById = shallowRef(
|
||||
new Map<string, ProjectVersionFilterOptionProjectMetadata[]>(),
|
||||
)
|
||||
const draftSelectedFilters = ref<AnalyticsSelectedFilters>(
|
||||
cloneSelectedFilters(selectedFilters.value),
|
||||
)
|
||||
let selectedFiltersCommitRequestId = 0
|
||||
let projectVersionFilterOptionsCacheKey = ''
|
||||
let projectVersionFilterOptionProjectMetadataCacheKey = ''
|
||||
|
||||
const selectedFilterValue = computed<Record<string, string[]>>({
|
||||
get: () => getSelectedFilterBarValue(),
|
||||
set: (nextValue) => {
|
||||
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
|
||||
|
||||
for (const [categoryKey, values] of Object.entries(nextValue)) {
|
||||
if (!isAnalyticsFilterValueCategory(categoryKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextFilters[categoryKey] = normalizeSelectedFilterValues(categoryKey, values, [])
|
||||
}
|
||||
|
||||
draftSelectedFilters.value = nextFilters
|
||||
void scheduleSelectedFiltersCommit()
|
||||
},
|
||||
})
|
||||
|
||||
function getSelectedFilterBarValue(): AnalyticsSelectedFilters {
|
||||
return cloneSelectedFilters(draftSelectedFilters.value)
|
||||
}
|
||||
|
||||
function clearSelectedBreakdown() {
|
||||
selectedBreakdowns.value = defaultSelectedBreakdown.value
|
||||
}
|
||||
|
||||
function clearFilterBar() {
|
||||
clearSelectedBreakdown()
|
||||
clearDownloadsThresholds()
|
||||
}
|
||||
|
||||
watch(queryResetToken, () => {
|
||||
selectedFiltersCommitRequestId++
|
||||
draftSelectedFilters.value = cloneSelectedFilters(selectedFilters.value)
|
||||
clearDownloadsThresholds()
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedFilters,
|
||||
(nextFilters, previousFilters) => {
|
||||
selectedFiltersCommitRequestId++
|
||||
draftSelectedFilters.value = cloneSelectedFilters(nextFilters)
|
||||
clearDownloadsThresholdsForChangedFilters(previousFilters, nextFilters)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
hasCompletedAnalyticsLoading,
|
||||
filterOptions,
|
||||
versionNumbersById,
|
||||
versionPublishedDatesById,
|
||||
versionProjectNamesById,
|
||||
],
|
||||
([
|
||||
hasCompletedLoading,
|
||||
nextFilterOptions,
|
||||
nextVersionNumbersById,
|
||||
nextVersionPublishedDatesById,
|
||||
nextVersionProjectNamesById,
|
||||
]) => {
|
||||
if (!hasCompletedLoading) {
|
||||
projectVersionFilterOptionsCacheKey = ''
|
||||
if (projectVersionFilterOptions.value.length > 0) {
|
||||
projectVersionFilterOptions.value = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextCacheKey = getProjectVersionFilterOptionsCacheKey(
|
||||
nextFilterOptions.versionIds,
|
||||
nextVersionNumbersById,
|
||||
nextVersionPublishedDatesById,
|
||||
nextVersionProjectNamesById,
|
||||
)
|
||||
if (nextCacheKey === projectVersionFilterOptionsCacheKey) {
|
||||
return
|
||||
}
|
||||
|
||||
projectVersionFilterOptionsCacheKey = nextCacheKey
|
||||
projectVersionFilterOptions.value = buildProjectVersionFilterOptions(
|
||||
nextFilterOptions.versionIds,
|
||||
nextVersionNumbersById,
|
||||
nextVersionPublishedDatesById,
|
||||
nextVersionProjectNamesById,
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
hasCompletedAnalyticsLoading,
|
||||
filterOptions,
|
||||
selectedFilters,
|
||||
versionProjectNamesById,
|
||||
versionProjectIconUrlsById,
|
||||
],
|
||||
([
|
||||
hasCompletedLoading,
|
||||
nextFilterOptions,
|
||||
nextSelectedFilters,
|
||||
nextVersionProjectNamesById,
|
||||
nextVersionProjectIconUrlsById,
|
||||
]) => {
|
||||
if (!hasCompletedLoading) {
|
||||
projectVersionFilterOptionProjectMetadataCacheKey = ''
|
||||
if (projectVersionFilterOptionProjectMetadataById.value.size > 0) {
|
||||
projectVersionFilterOptionProjectMetadataById.value = new Map()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const metadataIds = getProjectVersionFilterOptionMetadataIds(
|
||||
nextFilterOptions.versionIds,
|
||||
nextSelectedFilters.version_id,
|
||||
)
|
||||
const nextCacheKey = getProjectVersionFilterOptionProjectMetadataCacheKey(
|
||||
metadataIds,
|
||||
nextVersionProjectNamesById,
|
||||
nextVersionProjectIconUrlsById,
|
||||
)
|
||||
if (nextCacheKey === projectVersionFilterOptionProjectMetadataCacheKey) {
|
||||
return
|
||||
}
|
||||
|
||||
projectVersionFilterOptionProjectMetadataCacheKey = nextCacheKey
|
||||
projectVersionFilterOptionProjectMetadataById.value =
|
||||
buildProjectVersionFilterOptionProjectMetadataById(
|
||||
metadataIds,
|
||||
nextVersionProjectNamesById,
|
||||
nextVersionProjectIconUrlsById,
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function scheduleSelectedFiltersCommit() {
|
||||
const requestId = ++selectedFiltersCommitRequestId
|
||||
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
|
||||
|
||||
await waitForDeferredQueryFilterCommit()
|
||||
|
||||
if (requestId !== selectedFiltersCommitRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!areSelectedFiltersEqual(selectedFilters.value, nextFilters)) {
|
||||
selectedFilters.value = nextFilters
|
||||
}
|
||||
}
|
||||
|
||||
function waitForDeferredQueryFilterCommit(): Promise<void> {
|
||||
if (!import.meta.client) {
|
||||
return nextTick()
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
|
||||
const visibleCategoryKeys = new Set(
|
||||
getVisibleAnalyticsFilterCategoriesForState(selectedBreakdowns.value, selectedFilters.value),
|
||||
)
|
||||
const categories: DropdownFilterBarCategory[] = []
|
||||
|
||||
if (!hasProjectContext.value) {
|
||||
categories.push({
|
||||
key: 'project_status',
|
||||
label: formatMessage(analyticsBreakdownMessages.projectStatus),
|
||||
options: withSelectedOptions('project_status', projectStatusFilterOptions.value),
|
||||
})
|
||||
}
|
||||
|
||||
categories.push(
|
||||
{
|
||||
key: 'country',
|
||||
label: formatMessage(analyticsBreakdownMessages.country),
|
||||
searchable: countryFilterOptions.value.length > 6,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchCountriesPlaceholder),
|
||||
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
options: withSelectedOptions('country', countryFilterOptions.value),
|
||||
submenuClass: 'w-fit',
|
||||
previewDropdownWidth: 'fit-content',
|
||||
},
|
||||
{
|
||||
key: 'monetization',
|
||||
label: formatMessage(analyticsBreakdownMessages.monetization),
|
||||
options: withSelectedOptions('monetization', [
|
||||
{ value: 'monetized', label: formatMessage(analyticsMonetizationMessages.monetized) },
|
||||
{ value: 'unmonetized', label: formatMessage(analyticsMonetizationMessages.unmonetized) },
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: 'user_agent',
|
||||
label: formatMessage(analyticsBreakdownMessages.userAgent),
|
||||
searchable: downloadSourceFilterOptions.value.length > 6,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchDownloadSourcesPlaceholder),
|
||||
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
options: withSelectedOptions('user_agent', downloadSourceFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'download_reason',
|
||||
label: formatMessage(analyticsBreakdownMessages.downloadReason),
|
||||
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
|
||||
options: withSelectedOptions('download_reason', downloadReasonFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'version_id',
|
||||
label: formatMessage(analyticsBreakdownMessages.versionId),
|
||||
searchable: projectVersionFilterOptions.value.length > 6,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchProjectVersionsPlaceholder),
|
||||
submenuClass: 'w-fit',
|
||||
previewDropdownWidth: 'fit-content',
|
||||
options: withSelectedOptions('version_id', projectVersionFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'game_version',
|
||||
label: formatMessage(analyticsBreakdownMessages.gameVersion),
|
||||
searchable: true,
|
||||
searchPlaceholder: formatMessage(analyticsMessages.searchVersionsPlaceholder),
|
||||
submenuClass: 'w-fit max-w-[338px]',
|
||||
previewDropdownWidth: '338px',
|
||||
options: withSelectedOptions('game_version', gameVersionFilterOptions.value),
|
||||
},
|
||||
{
|
||||
key: 'loader_type',
|
||||
label: formatMessage(analyticsBreakdownMessages.loader),
|
||||
options: withSelectedOptions('loader_type', loaderTypeFilterOptions.value),
|
||||
},
|
||||
)
|
||||
|
||||
return categories.filter((category) =>
|
||||
visibleCategoryKeys.has(category.key as AnalyticsFilterValueCategory),
|
||||
)
|
||||
})
|
||||
|
||||
const countryLabelsByCode = computed(
|
||||
() =>
|
||||
new Map(
|
||||
formattedCountries.value.map(
|
||||
(country) => [country.value.toUpperCase(), country.label] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const countryFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.countries
|
||||
.map((countryCode) => ({
|
||||
value: countryCode,
|
||||
label: getCountryFilterOptionLabel(countryCode),
|
||||
searchTerms: [countryCode],
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
const gameVersionReleaseDatesByVersion = computed(
|
||||
() =>
|
||||
new Map(
|
||||
generatedState.value.gameVersions.map(
|
||||
(gameVersion) => [gameVersion.version, gameVersion.date] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
const gameVersionTypesByVersion = computed(
|
||||
() =>
|
||||
new Map(
|
||||
generatedState.value.gameVersions.map(
|
||||
(gameVersion) => [gameVersion.version, gameVersion.version_type] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const downloadSourceFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.downloadSources
|
||||
.map((downloadSource) => ({
|
||||
value: downloadSource,
|
||||
label: getDownloadSourceLabel(downloadSource, formatMessage),
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
const downloadReasonFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.downloadReasons.map((downloadReason) => ({
|
||||
value: downloadReason,
|
||||
label: getDownloadReasonFilterOptionLabel(downloadReason),
|
||||
})),
|
||||
)
|
||||
|
||||
const gameVersionFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.gameVersions
|
||||
.filter((gameVersion) => {
|
||||
const versionType = gameVersionTypesByVersion.value.get(gameVersion)
|
||||
return (
|
||||
gameVersionType.value === 'all' || versionType === undefined || versionType === 'release'
|
||||
)
|
||||
})
|
||||
.map((gameVersion) => ({
|
||||
value: gameVersion,
|
||||
label: gameVersion,
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
compareOptionalDateStringsDescending(
|
||||
gameVersionReleaseDatesByVersion.value.get(left.value),
|
||||
gameVersionReleaseDatesByVersion.value.get(right.value),
|
||||
left.label,
|
||||
right.label,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const loaderTypeFilterOptions = computed<DropdownFilterBarOption[]>(() =>
|
||||
filterOptions.value.loaderTypes
|
||||
.map((loaderType) => ({
|
||||
value: loaderType,
|
||||
label: getLoaderTypeFilterOptionLabel(loaderType),
|
||||
searchTerms: [loaderType],
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
)
|
||||
|
||||
function isAnalyticsFilterValueCategory(
|
||||
categoryKey: string,
|
||||
): categoryKey is AnalyticsFilterValueCategory {
|
||||
return filterValueCategoryKeys.has(categoryKey)
|
||||
}
|
||||
|
||||
function withSelectedOptions(
|
||||
categoryKey: AnalyticsFilterValueCategory,
|
||||
options: DropdownFilterBarOption[],
|
||||
): DropdownFilterBarOption[] {
|
||||
return getOptionsWithSelectedValues(
|
||||
options,
|
||||
selectedFilters.value[categoryKey],
|
||||
getMissingSelectedOptionLabel(categoryKey),
|
||||
)
|
||||
}
|
||||
|
||||
function getMissingSelectedOptionLabel(
|
||||
categoryKey: AnalyticsFilterValueCategory,
|
||||
): ((value: string) => string) | undefined {
|
||||
if (categoryKey === 'country') {
|
||||
return getCountryFilterOptionLabel
|
||||
}
|
||||
if (categoryKey === 'version_id') {
|
||||
return getVersionDisplayName
|
||||
}
|
||||
if (categoryKey === 'download_reason') {
|
||||
return getDownloadReasonFilterOptionLabel
|
||||
}
|
||||
if (categoryKey === 'user_agent') {
|
||||
return (value) => getDownloadSourceLabel(value, formatMessage)
|
||||
}
|
||||
if (categoryKey === 'loader_type') {
|
||||
return getLoaderTypeFilterOptionLabel
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getProjectVersionOptionProjectMetadata(versionId: string) {
|
||||
if (!showProjectVersionProjectIcons.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return projectVersionFilterOptionProjectMetadataById.value.get(versionId) ?? []
|
||||
}
|
||||
|
||||
function getCountryFilterOptionLabel(countryCode: string): string {
|
||||
const normalizedCode = countryCode.trim().toUpperCase()
|
||||
if (normalizedCode === 'XX') {
|
||||
return formatMessage(analyticsMessages.other)
|
||||
}
|
||||
|
||||
return countryLabelsByCode.value.get(normalizedCode) ?? countryCode
|
||||
}
|
||||
|
||||
function getProjectStatusFilterOptionLabel(status: string): string {
|
||||
return formatAnalyticsProjectStatusLabel(status, formatMessage)
|
||||
}
|
||||
|
||||
function getLoaderTypeFilterOptionLabel(loaderType: string): string {
|
||||
return formatAnalyticsLoaderLabel(loaderType, formatMessage)
|
||||
}
|
||||
|
||||
function getDownloadReasonFilterOptionLabel(reason: string): string {
|
||||
return formatAnalyticsDownloadReasonLabel(reason, formatMessage)
|
||||
}
|
||||
|
||||
function getDateTimestamp(date: string | undefined): number | undefined {
|
||||
if (!date) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timestamp = new Date(date).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined
|
||||
}
|
||||
|
||||
function compareOptionalDateStringsDescending(
|
||||
leftDate: string | undefined,
|
||||
rightDate: string | undefined,
|
||||
leftFallback: string,
|
||||
rightFallback: string,
|
||||
): number {
|
||||
const leftTimestamp = getDateTimestamp(leftDate)
|
||||
const rightTimestamp = getDateTimestamp(rightDate)
|
||||
|
||||
if (leftTimestamp !== undefined && rightTimestamp !== undefined) {
|
||||
return rightTimestamp - leftTimestamp
|
||||
}
|
||||
if (leftTimestamp !== undefined) {
|
||||
return -1
|
||||
}
|
||||
if (rightTimestamp !== undefined) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return leftFallback.localeCompare(rightFallback)
|
||||
}
|
||||
|
||||
function applyGameVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
|
||||
const threshold = gameVersionDownloadsThreshold.value
|
||||
if (threshold === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedValues = gameVersionFilterOptions.value
|
||||
.filter((gameVersion) => {
|
||||
return (gameVersionDownloadsByVersion.value.get(gameVersion.value) ?? 0) > threshold
|
||||
})
|
||||
.map((gameVersion) => gameVersion.value)
|
||||
|
||||
return setDownloadsThresholdSelectedValues('game_version', selectedValues, setSelectedValues)
|
||||
}
|
||||
|
||||
function applyCountryDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
|
||||
const threshold = countryDownloadsThreshold.value
|
||||
if (threshold === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedValues = countryFilterOptions.value
|
||||
.filter((country) => {
|
||||
return (countryDownloadsByCode.value.get(country.value.trim().toUpperCase()) ?? 0) > threshold
|
||||
})
|
||||
.map((country) => country.value)
|
||||
|
||||
return setDownloadsThresholdSelectedValues('country', selectedValues, setSelectedValues)
|
||||
}
|
||||
|
||||
function applyProjectVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
|
||||
const threshold = projectVersionDownloadsThreshold.value
|
||||
if (threshold === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedValues = projectVersionFilterOptions.value
|
||||
.filter((version) => {
|
||||
return (projectVersionDownloadsById.value.get(version.value) ?? 0) > threshold
|
||||
})
|
||||
.map((version) => version.value)
|
||||
|
||||
return setDownloadsThresholdSelectedValues('version_id', selectedValues, setSelectedValues)
|
||||
}
|
||||
|
||||
function setCountryDownloadsThreshold(
|
||||
threshold: number | null,
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
) {
|
||||
countryDownloadsThreshold.value = threshold
|
||||
if (threshold === null) {
|
||||
clearDownloadsThreshold('country')
|
||||
setSelectedValues([])
|
||||
return
|
||||
}
|
||||
|
||||
applyCountryDownloadsThreshold(setSelectedValues)
|
||||
}
|
||||
|
||||
function setProjectVersionDownloadsThreshold(
|
||||
threshold: number | null,
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
) {
|
||||
projectVersionDownloadsThreshold.value = threshold
|
||||
if (threshold === null) {
|
||||
clearDownloadsThreshold('version_id')
|
||||
setSelectedValues([])
|
||||
return
|
||||
}
|
||||
|
||||
applyProjectVersionDownloadsThreshold(setSelectedValues)
|
||||
}
|
||||
|
||||
function setGameVersionDownloadsThreshold(
|
||||
threshold: number | null,
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
) {
|
||||
gameVersionDownloadsThreshold.value = threshold
|
||||
if (threshold === null) {
|
||||
clearDownloadsThreshold('game_version')
|
||||
setSelectedValues([])
|
||||
return
|
||||
}
|
||||
|
||||
applyGameVersionDownloadsThreshold(setSelectedValues)
|
||||
}
|
||||
|
||||
function clearDownloadsThresholdsForChangedFilters(
|
||||
previousFilters: AnalyticsSelectedFilters,
|
||||
nextFilters: AnalyticsSelectedFilters,
|
||||
) {
|
||||
for (const categoryKey of downloadsThresholdFilterCategories) {
|
||||
if (areFilterSelectionsEqual(previousFilters[categoryKey], nextFilters[categoryKey])) {
|
||||
continue
|
||||
}
|
||||
|
||||
const thresholdSelection = downloadsThresholdSelections.value[categoryKey]
|
||||
if (
|
||||
thresholdSelection &&
|
||||
areFilterSelectionsEqual(thresholdSelection, nextFilters[categoryKey])
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousFilters[categoryKey].length > 0 || nextFilters[categoryKey].length > 0) {
|
||||
clearDownloadsThreshold(categoryKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDownloadsThresholdSelectedValues(
|
||||
categoryKey: DownloadsThresholdFilterCategory,
|
||||
selectedValues: string[],
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
): DownloadsThresholdSelection {
|
||||
const normalizedSelectedValues = normalizeSelectedFilterValues(categoryKey, selectedValues, [])
|
||||
downloadsThresholdSelections.value = {
|
||||
...downloadsThresholdSelections.value,
|
||||
[categoryKey]: normalizedSelectedValues,
|
||||
}
|
||||
setSelectedValues(selectedValues)
|
||||
|
||||
return {
|
||||
categoryKey,
|
||||
selectedValues: normalizedSelectedValues,
|
||||
}
|
||||
}
|
||||
|
||||
function clearDownloadsThreshold(categoryKey: DownloadsThresholdFilterCategory) {
|
||||
switch (categoryKey) {
|
||||
case 'country':
|
||||
countryDownloadsThreshold.value = null
|
||||
break
|
||||
case 'version_id':
|
||||
projectVersionDownloadsThreshold.value = null
|
||||
break
|
||||
case 'game_version':
|
||||
gameVersionDownloadsThreshold.value = null
|
||||
break
|
||||
}
|
||||
|
||||
const { [categoryKey]: _removedSelection, ...nextSelections } = downloadsThresholdSelections.value
|
||||
downloadsThresholdSelections.value = nextSelections
|
||||
}
|
||||
|
||||
function clearDownloadsThresholds() {
|
||||
for (const categoryKey of downloadsThresholdFilterCategories) {
|
||||
clearDownloadsThreshold(categoryKey)
|
||||
}
|
||||
}
|
||||
|
||||
function areFilterSelectionsEqual(left: string[], right: string[]): boolean {
|
||||
const leftValues = new Set(left)
|
||||
const rightValues = new Set(right)
|
||||
if (leftValues.size !== rightValues.size) {
|
||||
return false
|
||||
}
|
||||
|
||||
return [...leftValues].every((value) => rightValues.has(value))
|
||||
}
|
||||
|
||||
async function runDownloadsThresholdQuery(
|
||||
applyDownloadsThreshold: ApplyDownloadsThreshold,
|
||||
setSelectedValues: SetDropdownFilterValues,
|
||||
closeMenu: CloseDownloadsThresholdMenu,
|
||||
event?: KeyboardEvent,
|
||||
) {
|
||||
const selection = applyDownloadsThreshold(setSelectedValues)
|
||||
closeMenu(event)
|
||||
if (selection) {
|
||||
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
|
||||
nextFilters[selection.categoryKey] = selection.selectedValues
|
||||
draftSelectedFilters.value = nextFilters
|
||||
}
|
||||
await scheduleSelectedFiltersCommit()
|
||||
await refreshAnalyticsQuery()
|
||||
}
|
||||
|
||||
function setGameVersionType(type: TabsValue, setSelectedValues: SetDropdownFilterValues) {
|
||||
if (!isGameVersionType(type)) {
|
||||
return
|
||||
}
|
||||
|
||||
gameVersionType.value = type
|
||||
applyGameVersionDownloadsThreshold(setSelectedValues)
|
||||
}
|
||||
|
||||
function isGameVersionType(type: TabsValue): type is GameVersionType {
|
||||
return type === 'release' || type === 'all'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<BaseTimeFramePicker
|
||||
v-model:mode="selectedTimeframeMode"
|
||||
v-model:preset="selectedTimeframe"
|
||||
v-model:last-amount="selectedLastTimeframeAmount"
|
||||
v-model:last-unit="selectedLastTimeframeUnit"
|
||||
v-model:custom-start-date="selectedCustomTimeframeStartDate"
|
||||
v-model:custom-end-date="selectedCustomTimeframeEndDate"
|
||||
:min-date="ANALYTICS_START_DATE_INPUT_VALUE"
|
||||
:now-timestamp="queryRefreshTimestamp"
|
||||
:trigger-class="triggerClass"
|
||||
@open="handleTimeframeOpen"
|
||||
@commit="handleTimeframeCommit"
|
||||
@apply="handleTimeframeApply"
|
||||
@draft-change="handleTimeframeDraftChange"
|
||||
@preset-select="handleTimeframePresetSelect"
|
||||
>
|
||||
<template #prefix>
|
||||
<slot name="prefix"></slot>
|
||||
</template>
|
||||
</BaseTimeFramePicker>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ComboboxOption,
|
||||
TimeFramePicker as BaseTimeFramePicker,
|
||||
type TimeFramePickerSelection,
|
||||
type TimeFramePreset,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
ANALYTICS_START_DATE_INPUT_VALUE,
|
||||
type AnalyticsGroupByPreset,
|
||||
type AnalyticsTimeframePreset,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
ensureMinimumTimeRange,
|
||||
getAnalyticsTimeRange,
|
||||
getDateInputValue,
|
||||
getDefaultAnalyticsGroupByForDurationMinutes,
|
||||
} from './timeframe'
|
||||
|
||||
const {
|
||||
selectedTimeframeMode,
|
||||
selectedTimeframe,
|
||||
selectedLastTimeframeAmount,
|
||||
selectedLastTimeframeUnit,
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy,
|
||||
queryRefreshTimestamp,
|
||||
analyticsAllTimeStartDate,
|
||||
refreshAnalyticsQuery,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
|
||||
defineProps<{
|
||||
triggerClass?: string
|
||||
}>()
|
||||
|
||||
const draftSelectedGroupBy = ref(selectedGroupBy.value)
|
||||
|
||||
const defaultGroupByForPreset: Partial<Record<AnalyticsTimeframePreset, AnalyticsGroupByPreset>> = {
|
||||
today: '1h',
|
||||
yesterday: '1h',
|
||||
last_7_days: '6h',
|
||||
last_14_days: 'day',
|
||||
last_30_days: 'day',
|
||||
last_90_days: 'day',
|
||||
last_180_days: 'week',
|
||||
year_to_date: 'week',
|
||||
}
|
||||
|
||||
function handleTimeframeOpen() {
|
||||
draftSelectedGroupBy.value = selectedGroupBy.value
|
||||
}
|
||||
|
||||
function handleTimeframeCommit() {
|
||||
selectedGroupBy.value = draftSelectedGroupBy.value
|
||||
}
|
||||
|
||||
async function handleTimeframeApply() {
|
||||
await refreshAnalyticsQuery()
|
||||
}
|
||||
|
||||
function handleTimeframePresetSelect(option: ComboboxOption<TimeFramePreset>) {
|
||||
const defaultGroupBy = defaultGroupByForPreset[option.value as AnalyticsTimeframePreset]
|
||||
if (defaultGroupBy) {
|
||||
draftSelectedGroupBy.value = defaultGroupBy
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeframeDraftChange(selection: TimeFramePickerSelection) {
|
||||
if (selection.mode !== 'last' && selection.mode !== 'custom_range') {
|
||||
return
|
||||
}
|
||||
if (selection.mode === 'custom_range' && !hasCompleteCustomDateRange(selection)) {
|
||||
return
|
||||
}
|
||||
|
||||
const range = getAnalyticsTimeRange({
|
||||
mode: selection.mode,
|
||||
preset: selection.preset,
|
||||
lastAmount: selection.lastAmount,
|
||||
lastUnit: selection.lastUnit,
|
||||
customStartDate: selection.customStartDate,
|
||||
customEndDate: selection.customEndDate,
|
||||
nowTimestamp: queryRefreshTimestamp.value,
|
||||
allTimeStartDate: analyticsAllTimeStartDate.value,
|
||||
})
|
||||
const { start, end } = ensureMinimumTimeRange(range.start, range.end)
|
||||
const durationMinutes = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 60000))
|
||||
draftSelectedGroupBy.value = getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
|
||||
}
|
||||
|
||||
function hasCompleteCustomDateRange(selection: TimeFramePickerSelection) {
|
||||
return Boolean(
|
||||
getDateFromInputValue(selection.customStartDate) &&
|
||||
getDateFromInputValue(selection.customEndDate),
|
||||
)
|
||||
}
|
||||
|
||||
function getDateFromInputValue(value: string): Date | undefined {
|
||||
const date = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime()) || getDateInputValue(date) !== value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return date
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,496 @@
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsQueryFilterCategory,
|
||||
AnalyticsSelectedFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export type AnalyticsDashboardDimension =
|
||||
| 'project'
|
||||
| 'project_status'
|
||||
| 'version_id'
|
||||
| 'country'
|
||||
| 'monetization'
|
||||
| 'user_agent'
|
||||
| 'download_reason'
|
||||
| 'game_version'
|
||||
| 'loader_type'
|
||||
|
||||
export const ALL_FILTER_VALUE = '__all__'
|
||||
export const FILTER_VALUE_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
|
||||
'project_status',
|
||||
'country',
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'version_id',
|
||||
'game_version',
|
||||
'loader_type',
|
||||
]
|
||||
|
||||
const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
const ANALYTICS_STATS_BY_DIMENSION: Record<
|
||||
AnalyticsDashboardDimension,
|
||||
readonly AnalyticsDashboardStat[]
|
||||
> = {
|
||||
project: ANALYTICS_DASHBOARD_STAT_ORDER,
|
||||
version_id: ['downloads', 'playtime'],
|
||||
country: ['views', 'downloads', 'playtime'],
|
||||
monetization: ['views', 'downloads'],
|
||||
user_agent: ['downloads'],
|
||||
download_reason: ['downloads'],
|
||||
game_version: ['downloads', 'playtime'],
|
||||
loader_type: ['downloads', 'playtime'],
|
||||
project_status: ANALYTICS_DASHBOARD_STAT_ORDER,
|
||||
}
|
||||
|
||||
const ANALYTICS_DIMENSION_BY_BREAKDOWN: Record<
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardDimension
|
||||
> = {
|
||||
none: 'project',
|
||||
project: 'project',
|
||||
country: 'country',
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
version_id: 'version_id',
|
||||
loader: 'loader_type',
|
||||
game_version: 'game_version',
|
||||
}
|
||||
|
||||
const ANALYTICS_DIMENSION_BY_FILTER_CATEGORY: Record<
|
||||
Exclude<AnalyticsQueryFilterCategory, 'project'>,
|
||||
AnalyticsDashboardDimension
|
||||
> = {
|
||||
project_status: 'project_status',
|
||||
country: 'country',
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
version_id: 'version_id',
|
||||
game_version: 'game_version',
|
||||
loader_type: 'loader_type',
|
||||
}
|
||||
|
||||
const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
|
||||
AnalyticsBreakdownPreset,
|
||||
Exclude<AnalyticsQueryFilterCategory, 'project'> | null
|
||||
> = {
|
||||
none: null,
|
||||
project: null,
|
||||
country: 'country',
|
||||
monetization: 'monetization',
|
||||
user_agent: 'user_agent',
|
||||
download_reason: 'download_reason',
|
||||
version_id: 'version_id',
|
||||
loader: 'loader_type',
|
||||
game_version: 'game_version',
|
||||
}
|
||||
|
||||
export type FilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
searchTerms?: string[]
|
||||
}
|
||||
|
||||
export type ProjectVersionFilterOption = FilterOption
|
||||
|
||||
export type ProjectVersionFilterOptionProjectMetadata = {
|
||||
name: string
|
||||
iconUrl?: string
|
||||
}
|
||||
|
||||
type AnalyticsBreakdownInput = AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[]
|
||||
|
||||
function getOptionalDateTimestamp(date: string | undefined): number | undefined {
|
||||
if (!date) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timestamp = new Date(date).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined
|
||||
}
|
||||
|
||||
export function getProjectVersionFilterOptionsCacheKey(
|
||||
versionIds: string[],
|
||||
versionNumbersById: Map<string, string>,
|
||||
versionPublishedDatesById: Map<string, string>,
|
||||
versionProjectNamesById: Map<string, string>,
|
||||
): string {
|
||||
return versionIds
|
||||
.map(
|
||||
(versionId) =>
|
||||
`${versionId}\x1f${versionNumbersById.get(versionId) ?? ''}\x1f${
|
||||
versionPublishedDatesById.get(versionId) ?? ''
|
||||
}\x1f${versionProjectNamesById.get(versionId) ?? ''}`,
|
||||
)
|
||||
.join('\x1e')
|
||||
}
|
||||
|
||||
export function getProjectVersionFilterOptionProjectMetadataCacheKey(
|
||||
versionIds: string[],
|
||||
versionProjectNamesById: Map<string, string>,
|
||||
versionProjectIconUrlsById: Map<string, string>,
|
||||
): string {
|
||||
return versionIds
|
||||
.map(
|
||||
(versionId) =>
|
||||
`${versionId}\x1f${versionProjectNamesById.get(versionId) ?? ''}\x1f${
|
||||
versionProjectIconUrlsById.get(versionId) ?? ''
|
||||
}`,
|
||||
)
|
||||
.join('\x1e')
|
||||
}
|
||||
|
||||
export function getProjectVersionFilterOptionMetadataIds(
|
||||
versionIds: string[],
|
||||
selectedVersionIds: string[],
|
||||
): string[] {
|
||||
const knownVersionIds = new Set(versionIds)
|
||||
const metadataIds = [...versionIds]
|
||||
|
||||
for (const versionId of selectedVersionIds) {
|
||||
if (!knownVersionIds.has(versionId)) {
|
||||
metadataIds.push(versionId)
|
||||
knownVersionIds.add(versionId)
|
||||
}
|
||||
}
|
||||
|
||||
return metadataIds
|
||||
}
|
||||
|
||||
export function buildProjectVersionFilterOptions(
|
||||
versionIds: string[],
|
||||
versionNumbersById: Map<string, string>,
|
||||
versionPublishedDatesById: Map<string, string>,
|
||||
versionProjectNamesById: Map<string, string>,
|
||||
): ProjectVersionFilterOption[] {
|
||||
return versionIds
|
||||
.map((versionId) => {
|
||||
const projectName = versionProjectNamesById.get(versionId)
|
||||
return {
|
||||
option: {
|
||||
value: versionId,
|
||||
label: versionNumbersById.get(versionId) ?? versionId,
|
||||
searchTerms: projectName ? [versionId, projectName] : [versionId],
|
||||
},
|
||||
publishedTimestamp: getOptionalDateTimestamp(versionPublishedDatesById.get(versionId)),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.publishedTimestamp !== undefined && right.publishedTimestamp !== undefined) {
|
||||
return right.publishedTimestamp - left.publishedTimestamp
|
||||
}
|
||||
if (left.publishedTimestamp !== undefined) {
|
||||
return -1
|
||||
}
|
||||
if (right.publishedTimestamp !== undefined) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return left.option.label.localeCompare(right.option.label)
|
||||
})
|
||||
.map(({ option }) => option)
|
||||
}
|
||||
|
||||
export function buildProjectVersionFilterOptionProjectMetadataById(
|
||||
versionIds: string[],
|
||||
versionProjectNamesById: Map<string, string>,
|
||||
versionProjectIconUrlsById: Map<string, string>,
|
||||
): Map<string, ProjectVersionFilterOptionProjectMetadata[]> {
|
||||
const metadataById = new Map<string, ProjectVersionFilterOptionProjectMetadata[]>()
|
||||
|
||||
for (const versionId of versionIds) {
|
||||
const projectName = versionProjectNamesById.get(versionId)
|
||||
if (!projectName) {
|
||||
continue
|
||||
}
|
||||
|
||||
const metadata: ProjectVersionFilterOptionProjectMetadata = { name: projectName }
|
||||
const iconUrl = versionProjectIconUrlsById.get(versionId)
|
||||
if (iconUrl) {
|
||||
metadata.iconUrl = iconUrl
|
||||
}
|
||||
|
||||
metadataById.set(versionId, [metadata])
|
||||
}
|
||||
|
||||
return metadataById
|
||||
}
|
||||
|
||||
function intersectAnalyticsStats(
|
||||
left: readonly AnalyticsDashboardStat[],
|
||||
right: readonly AnalyticsDashboardStat[],
|
||||
): AnalyticsDashboardStat[] {
|
||||
const rightStats = new Set(right)
|
||||
return left.filter((stat) => rightStats.has(stat))
|
||||
}
|
||||
|
||||
function haveAnalyticsStatOverlap(
|
||||
left: readonly AnalyticsDashboardStat[],
|
||||
right: readonly AnalyticsDashboardStat[],
|
||||
): boolean {
|
||||
return left.some((stat) => right.includes(stat))
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForDimension(
|
||||
dimension: AnalyticsDashboardDimension,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
return ANALYTICS_STATS_BY_DIMENSION[dimension]
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForBreakdown(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_BREAKDOWN[breakdown])
|
||||
}
|
||||
|
||||
function normalizeAnalyticsBreakdowns(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
): Exclude<AnalyticsBreakdownPreset, 'none'>[] {
|
||||
const values = Array.isArray(breakdowns) ? breakdowns : [breakdowns]
|
||||
const normalizedBreakdowns: Exclude<AnalyticsBreakdownPreset, 'none'>[] = []
|
||||
|
||||
for (const breakdown of values) {
|
||||
if (breakdown === 'none') {
|
||||
continue
|
||||
}
|
||||
if (!normalizedBreakdowns.includes(breakdown)) {
|
||||
normalizedBreakdowns.push(breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedBreakdowns
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForBreakdowns(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
|
||||
if (normalizedBreakdowns.length === 0) {
|
||||
return getAnalyticsStatsForBreakdown('none')
|
||||
}
|
||||
|
||||
let stats = [...getAnalyticsStatsForBreakdown(normalizedBreakdowns[0])]
|
||||
for (const breakdown of normalizedBreakdowns.slice(1)) {
|
||||
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForBreakdown(breakdown))
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
export function getAnalyticsStatsForFilterCategory(
|
||||
category: AnalyticsQueryFilterCategory,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
if (category === 'project') {
|
||||
return ANALYTICS_DASHBOARD_STAT_ORDER
|
||||
}
|
||||
|
||||
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_FILTER_CATEGORY[category])
|
||||
}
|
||||
|
||||
export function getAnalyticsFilterCategoryForBreakdown(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
): Exclude<AnalyticsQueryFilterCategory, 'project'> | null {
|
||||
return ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN[breakdown]
|
||||
}
|
||||
|
||||
function getAnalyticsStatsForFilterScope(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
ignoredCategory?: AnalyticsQueryFilterCategory,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
let stats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
|
||||
|
||||
for (const category of FILTER_VALUE_CATEGORIES) {
|
||||
if (category === ignoredCategory || filters[category].length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForFilterCategory(category))
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
export function getEnabledAnalyticsStatsForState(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): readonly AnalyticsDashboardStat[] {
|
||||
return getAnalyticsStatsForFilterScope(breakdowns, filters)
|
||||
}
|
||||
|
||||
export function getVisibleAnalyticsFilterCategoriesForState(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): readonly Exclude<AnalyticsQueryFilterCategory, 'project'>[] {
|
||||
return FILTER_VALUE_CATEGORIES.filter((category) =>
|
||||
haveAnalyticsStatOverlap(
|
||||
getAnalyticsStatsForFilterScope(breakdowns, filters, category),
|
||||
getAnalyticsStatsForFilterCategory(category),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeAnalyticsSelectedFilters(
|
||||
breakdowns: AnalyticsBreakdownInput,
|
||||
filters: AnalyticsSelectedFilters,
|
||||
): AnalyticsSelectedFilters {
|
||||
const nextFilters = cloneSelectedFilters(filters)
|
||||
let availableStats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
|
||||
|
||||
for (const category of FILTER_VALUE_CATEGORIES) {
|
||||
if (filters[category].length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const categoryStats = getAnalyticsStatsForFilterCategory(category)
|
||||
if (!haveAnalyticsStatOverlap(availableStats, categoryStats)) {
|
||||
nextFilters[category] = []
|
||||
continue
|
||||
}
|
||||
|
||||
availableStats = intersectAnalyticsStats(availableStats, categoryStats)
|
||||
}
|
||||
|
||||
return nextFilters
|
||||
}
|
||||
|
||||
export function cloneSelectedFilters(filters: AnalyticsSelectedFilters): AnalyticsSelectedFilters {
|
||||
return {
|
||||
project: [...filters.project],
|
||||
project_status: [...filters.project_status],
|
||||
country: [...filters.country],
|
||||
monetization: [...filters.monetization],
|
||||
user_agent: [...filters.user_agent],
|
||||
download_reason: [...filters.download_reason],
|
||||
version_id: [...filters.version_id],
|
||||
game_version: [...filters.game_version],
|
||||
loader_type: [...filters.loader_type],
|
||||
}
|
||||
}
|
||||
|
||||
export function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function areSelectedFiltersEqual(
|
||||
left: AnalyticsSelectedFilters,
|
||||
right: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
if (!areStringArraysEqual(left.project, right.project)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const categoryKey of FILTER_VALUE_CATEGORIES) {
|
||||
if (!areStringArraysEqual(left[categoryKey], right[categoryKey])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function getOptionsWithSelectedValues(
|
||||
options: FilterOption[],
|
||||
selectedValues: string[],
|
||||
getMissingSelectedOptionLabel: (value: string) => string = (value) => value,
|
||||
): FilterOption[] {
|
||||
if (selectedValues.length === 0) {
|
||||
return options
|
||||
}
|
||||
|
||||
const knownValues = new Set(options.map((option) => option.value))
|
||||
const missingSelectedOptions = selectedValues
|
||||
.filter((value) => !knownValues.has(value))
|
||||
.map((value) => ({
|
||||
value,
|
||||
label: getMissingSelectedOptionLabel(value),
|
||||
}))
|
||||
|
||||
return missingSelectedOptions.length === 0 ? options : [...options, ...missingSelectedOptions]
|
||||
}
|
||||
|
||||
export function normalizeSelectedValues(
|
||||
categoryKey: AnalyticsQueryFilterCategory,
|
||||
values: string[],
|
||||
projectIds: string[],
|
||||
): string[] {
|
||||
const uniqueValues = Array.from(new Set(values))
|
||||
|
||||
if (categoryKey === 'project') {
|
||||
if (uniqueValues.includes(ALL_FILTER_VALUE)) {
|
||||
return projectIds
|
||||
}
|
||||
|
||||
const allProjectIds = new Set(projectIds)
|
||||
const selectedProjects = uniqueValues.filter((value) => allProjectIds.has(value))
|
||||
|
||||
return selectedProjects.length > 0 ? selectedProjects : projectIds
|
||||
}
|
||||
|
||||
if (uniqueValues.includes(ALL_FILTER_VALUE) || uniqueValues.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const selectedValues = uniqueValues.filter((value) => value !== ALL_FILTER_VALUE)
|
||||
if (categoryKey === 'project_status') {
|
||||
return selectedValues
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(isProjectStatusFilterValue)
|
||||
}
|
||||
if (categoryKey === 'loader_type') {
|
||||
return Array.from(
|
||||
new Set(
|
||||
selectedValues
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return selectedValues
|
||||
}
|
||||
|
||||
export const PROJECT_STATUS_FILTER_VALUES = [
|
||||
'approved',
|
||||
'archived',
|
||||
'rejected',
|
||||
'draft',
|
||||
'unlisted',
|
||||
'withheld',
|
||||
'private',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export type ProjectStatusFilterValue = (typeof PROJECT_STATUS_FILTER_VALUES)[number]
|
||||
|
||||
const projectStatusFilterValueSet = new Set<string>(PROJECT_STATUS_FILTER_VALUES)
|
||||
|
||||
export function isProjectStatusFilterValue(value: string): value is ProjectStatusFilterValue {
|
||||
return projectStatusFilterValueSet.has(value)
|
||||
}
|
||||
|
||||
export function getProjectStatusFilterValue(
|
||||
status: string | null | undefined,
|
||||
): ProjectStatusFilterValue {
|
||||
const normalizedStatus = status?.trim().toLowerCase() ?? ''
|
||||
return isProjectStatusFilterValue(normalizedStatus) ? normalizedStatus : 'other'
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import {
|
||||
type AnalyticsGroupByPreset,
|
||||
type AnalyticsLastTimeframeUnit,
|
||||
type AnalyticsTimeframeMode,
|
||||
type AnalyticsTimeframePreset,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
const MIN_RANGE_MS = 60 * 60 * 1000
|
||||
const TIME_RANGE_ROUNDING_MS = 60 * 1000
|
||||
export const MAX_ANALYTICS_TIME_SLICES = 256
|
||||
|
||||
const GROUP_BY_PRESET_MINUTES: Record<AnalyticsGroupByPreset, number> = {
|
||||
'1h': 60,
|
||||
'6h': 360,
|
||||
day: 24 * 60,
|
||||
week: 7 * 24 * 60,
|
||||
month: 30 * 24 * 60,
|
||||
year: 365 * 24 * 60,
|
||||
}
|
||||
|
||||
export type AnalyticsTimeRange = {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
export function startOfDay(date: Date): Date {
|
||||
const nextDate = new Date(date)
|
||||
nextDate.setHours(0, 0, 0, 0)
|
||||
return nextDate
|
||||
}
|
||||
|
||||
export function getRoundedNow(timestamp: number): Date {
|
||||
const roundedTimestamp = Math.floor(timestamp / TIME_RANGE_ROUNDING_MS) * TIME_RANGE_ROUNDING_MS
|
||||
return new Date(roundedTimestamp)
|
||||
}
|
||||
|
||||
export function getDateInputValue(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function parseDateInputValue(value: string): Date {
|
||||
const parsedDate = new Date(`${value}T00:00:00`)
|
||||
return Number.isNaN(parsedDate.getTime()) ? startOfDay(new Date()) : parsedDate
|
||||
}
|
||||
|
||||
export function parseDateTimeInputValue(value: string): Date {
|
||||
const parsedDate = new Date(value)
|
||||
return Number.isNaN(parsedDate.getTime()) ? getRoundedNow(Date.now()) : parsedDate
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const nextDate = new Date(date)
|
||||
nextDate.setDate(nextDate.getDate() + days)
|
||||
return nextDate
|
||||
}
|
||||
|
||||
function isStartOfDay(date: Date): boolean {
|
||||
return (
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0 &&
|
||||
date.getMilliseconds() === 0
|
||||
)
|
||||
}
|
||||
|
||||
export function getInclusiveEndDateInputValue(end: Date): string {
|
||||
return getDateInputValue(isStartOfDay(end) ? addDays(end, -1) : end)
|
||||
}
|
||||
|
||||
function subtractCalendarMonths(date: Date, months: number): Date {
|
||||
const nextDate = new Date(date)
|
||||
const day = nextDate.getDate()
|
||||
nextDate.setDate(1)
|
||||
nextDate.setMonth(nextDate.getMonth() - months)
|
||||
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
|
||||
nextDate.setDate(Math.min(day, daysInMonth))
|
||||
return nextDate
|
||||
}
|
||||
|
||||
export function getTimeRangeForPreset(
|
||||
preset: AnalyticsTimeframePreset,
|
||||
nowTimestamp: number,
|
||||
allTimeStartDate: Date = new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
|
||||
): AnalyticsTimeRange {
|
||||
const now = getRoundedNow(nowTimestamp)
|
||||
const end = new Date(now)
|
||||
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { start: startOfDay(now), end }
|
||||
case 'yesterday': {
|
||||
const todayStart = startOfDay(now)
|
||||
return {
|
||||
start: new Date(todayStart.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: todayStart,
|
||||
}
|
||||
}
|
||||
case 'last_7_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'last_14_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 14 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'last_30_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'last_90_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'last_180_days':
|
||||
return {
|
||||
start: new Date(end.getTime() - 180 * 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
case 'year_to_date': {
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1)
|
||||
yearStart.setHours(0, 0, 0, 0)
|
||||
return { start: yearStart, end }
|
||||
}
|
||||
case 'all_time':
|
||||
return {
|
||||
start: new Date(allTimeStartDate),
|
||||
end,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
start: new Date(end.getTime() - 24 * 60 * 60 * 1000),
|
||||
end,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeRangeForLastTimeframe(
|
||||
amountValue: number,
|
||||
unit: AnalyticsLastTimeframeUnit,
|
||||
nowTimestamp: number,
|
||||
): AnalyticsTimeRange {
|
||||
const end = getRoundedNow(nowTimestamp)
|
||||
const amount = Math.max(1, Math.floor(amountValue))
|
||||
|
||||
switch (unit) {
|
||||
case 'hours':
|
||||
return { start: new Date(end.getTime() - amount * 60 * 60 * 1000), end }
|
||||
case 'days':
|
||||
return { start: new Date(end.getTime() - amount * 24 * 60 * 60 * 1000), end }
|
||||
case 'weeks':
|
||||
return { start: new Date(end.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end }
|
||||
case 'months':
|
||||
return { start: subtractCalendarMonths(end, amount), end }
|
||||
default:
|
||||
return { start: new Date(end.getTime() - 24 * 60 * 60 * 1000), end }
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeRangeForCustomDateRange(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AnalyticsTimeRange {
|
||||
const start = parseDateInputValue(startDate)
|
||||
const inclusiveEnd = parseDateInputValue(endDate)
|
||||
return {
|
||||
start,
|
||||
end: addDays(inclusiveEnd, 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeRangeForCustomDateTimeRange(
|
||||
startDateTime: string,
|
||||
endDateTime: string,
|
||||
): AnalyticsTimeRange {
|
||||
return {
|
||||
start: parseDateTimeInputValue(startDateTime),
|
||||
end: parseDateTimeInputValue(endDateTime),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsTimeRange({
|
||||
mode,
|
||||
preset,
|
||||
lastAmount,
|
||||
lastUnit,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
nowTimestamp,
|
||||
allTimeStartDate,
|
||||
}: {
|
||||
mode: AnalyticsTimeframeMode
|
||||
preset: AnalyticsTimeframePreset
|
||||
lastAmount: number
|
||||
lastUnit: AnalyticsLastTimeframeUnit
|
||||
customStartDate: string
|
||||
customEndDate: string
|
||||
nowTimestamp: number
|
||||
allTimeStartDate?: Date
|
||||
}): AnalyticsTimeRange {
|
||||
switch (mode) {
|
||||
case 'last':
|
||||
return getTimeRangeForLastTimeframe(lastAmount, lastUnit, nowTimestamp)
|
||||
case 'custom_range':
|
||||
return getTimeRangeForCustomDateRange(customStartDate, customEndDate)
|
||||
case 'custom_datetime_range':
|
||||
return getTimeRangeForCustomDateTimeRange(customStartDate, customEndDate)
|
||||
case 'preset':
|
||||
default:
|
||||
return getTimeRangeForPreset(preset, nowTimestamp, allTimeStartDate)
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsGroupByForDurationMinutes(
|
||||
durationMinutes: number,
|
||||
): AnalyticsGroupByPreset {
|
||||
const days = durationMinutes / (24 * 60)
|
||||
if (days <= 2) return '1h'
|
||||
if (days <= 7) return '6h'
|
||||
if (days <= 90) return 'day'
|
||||
if (days <= 365) return 'week'
|
||||
if (days <= 365 * 3) return 'month'
|
||||
return 'year'
|
||||
}
|
||||
|
||||
export function getAnalyticsGroupByPresetMinutes(preset: AnalyticsGroupByPreset): number {
|
||||
return GROUP_BY_PRESET_MINUTES[preset]
|
||||
}
|
||||
|
||||
export function isAnalyticsGroupByAvailableForDurationMinutes(
|
||||
preset: AnalyticsGroupByPreset,
|
||||
durationMinutes: number,
|
||||
): boolean {
|
||||
const groupByMinutes = getAnalyticsGroupByPresetMinutes(preset)
|
||||
const isTooCoarse = groupByMinutes >= durationMinutes
|
||||
const isTooFine = durationMinutes / groupByMinutes > MAX_ANALYTICS_TIME_SLICES
|
||||
|
||||
return !isTooCoarse && !isTooFine
|
||||
}
|
||||
|
||||
export function ensureMinimumTimeRange(start: Date, end: Date): AnalyticsTimeRange {
|
||||
if (end.getTime() <= start.getTime()) {
|
||||
return {
|
||||
start: new Date(end.getTime() - MIN_RANGE_MS),
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
if (end.getTime() - start.getTime() < MIN_RANGE_MS) {
|
||||
return {
|
||||
start: new Date(end.getTime() - MIN_RANGE_MS),
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function useSelectedAnalyticsTimeRange() {
|
||||
const {
|
||||
selectedTimeframeMode,
|
||||
selectedTimeframe,
|
||||
selectedLastTimeframeAmount,
|
||||
selectedLastTimeframeUnit,
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
queryRefreshTimestamp,
|
||||
analyticsAllTimeStartDate,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
|
||||
const selectedTimeRange = computed(() =>
|
||||
getAnalyticsTimeRange({
|
||||
mode: selectedTimeframeMode.value,
|
||||
preset: selectedTimeframe.value,
|
||||
lastAmount: selectedLastTimeframeAmount.value,
|
||||
lastUnit: selectedLastTimeframeUnit.value,
|
||||
customStartDate: selectedCustomTimeframeStartDate.value,
|
||||
customEndDate: selectedCustomTimeframeEndDate.value,
|
||||
nowTimestamp: queryRefreshTimestamp.value,
|
||||
allTimeStartDate: analyticsAllTimeStartDate.value,
|
||||
}),
|
||||
)
|
||||
|
||||
const selectedTimeframeDurationMinutes = computed(() => {
|
||||
const { start, end } = ensureMinimumTimeRange(
|
||||
selectedTimeRange.value.start,
|
||||
selectedTimeRange.value.end,
|
||||
)
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
return Math.max(1, Math.floor(durationMs / (60 * 1000)))
|
||||
})
|
||||
|
||||
return {
|
||||
selectedTimeRange,
|
||||
selectedTimeframeDurationMinutes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip="disabled ? formatMessage(analyticsStatCardMessages.unavailableTooltip) : ''"
|
||||
type="button"
|
||||
class="flex h-full appearance-none flex-col gap-2.5 rounded-2xl border border-solid p-5 px-4 text-left transition-colors sm:gap-4"
|
||||
:class="{
|
||||
'cursor-not-allowed border-surface-5 bg-surface-2 opacity-60': disabled,
|
||||
'cursor-default border-brand bg-highlight-green': !disabled && active,
|
||||
'border-surface-5 bg-surface-3 hover:bg-surface-4 active:scale-95': !disabled && !active,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div
|
||||
class="text-base font-medium"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-primary': !disabled,
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-center justify-center">
|
||||
<component
|
||||
:is="iconComponent"
|
||||
aria-hidden="true"
|
||||
class="size-5 sm:size-6"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-brand': !disabled && active,
|
||||
'text-primary': !disabled && !active,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div
|
||||
v-tooltip="!disabled ? statTooltip : undefined"
|
||||
class="w-fit text-2xl font-semibold leading-none md:text-4xl"
|
||||
:class="{
|
||||
'text-primary': disabled,
|
||||
'text-contrast': !disabled,
|
||||
}"
|
||||
>
|
||||
{{ disabled ? '-' : statLabel }}
|
||||
</div>
|
||||
|
||||
<template v-if="disabled">
|
||||
<span class="inline-flex items-center gap-1 text-xs text-secondary">
|
||||
{{ formatMessage(analyticsStatCardMessages.unavailableLabel) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="vsPrevPeriodPercent" class="flex items-center gap-1 text-sm">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-green': !disabled && trendValue > 0,
|
||||
'text-red': !disabled && trendValue < 0,
|
||||
'text-primary': !disabled && trendValue === 0,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="trendDirectionIcon"
|
||||
v-if="showTrendDirectionIcon"
|
||||
aria-hidden="true"
|
||||
class="size-3"
|
||||
/>
|
||||
{{ vsPrevPeriodPercent }}
|
||||
</span>
|
||||
<span
|
||||
class="mt-px text-xs max-sm:hidden"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-primary': !disabled,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(analyticsStatCardMessages.previousPeriodComparison) }}
|
||||
</span>
|
||||
<span
|
||||
class="visible mt-px text-xs sm:hidden"
|
||||
:class="{
|
||||
'text-secondary': disabled,
|
||||
'text-primary': !disabled,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(analyticsStatCardMessages.previousPeriodComparisonShort) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClockIcon,
|
||||
CurrencyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
type IconComponent,
|
||||
PlayIcon,
|
||||
TimerIcon,
|
||||
TrendingDownIcon,
|
||||
TrendingUpIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsStatCardMessages } from '../analytics-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
statLabel: string
|
||||
statTooltip?: string
|
||||
vsPrevPeriodPercent: string | null
|
||||
icon: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'click'): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const statCardIconMap: Record<string, IconComponent> = {
|
||||
clock: ClockIcon,
|
||||
timer: TimerIcon,
|
||||
play: PlayIcon,
|
||||
eye: EyeIcon,
|
||||
download: DownloadIcon,
|
||||
currency: CurrencyIcon,
|
||||
dollar: CurrencyIcon,
|
||||
}
|
||||
|
||||
const iconComponent = computed<IconComponent>(() => {
|
||||
const normalizedIconName = props.icon
|
||||
.toLowerCase()
|
||||
.replace(/icon$/u, '')
|
||||
.replace(/[^a-z]/gu, '')
|
||||
return statCardIconMap[normalizedIconName] ?? ClockIcon
|
||||
})
|
||||
|
||||
const trendValue = computed(() => {
|
||||
const parsed = Number.parseFloat(props.vsPrevPeriodPercent?.replace(/[^0-9.-]/gu, '') ?? '')
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
})
|
||||
|
||||
const showTrendDirectionIcon = computed(() => !props.disabled && trendValue.value !== 0)
|
||||
|
||||
const trendDirectionIcon = computed<IconComponent>(() =>
|
||||
trendValue.value >= 0 ? TrendingUpIcon : TrendingDownIcon,
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<Admonition
|
||||
v-if="showMonetizationBanner"
|
||||
type="info"
|
||||
:header="formatMessage(analyticsStatCardMessages.monetizationBannerTitle)"
|
||||
show-actions-underneath
|
||||
dismissible
|
||||
@dismiss="dismissMonetizationBanner"
|
||||
>
|
||||
<div class="text-primary">
|
||||
{{ formatMessage(analyticsStatCardMessages.monetizationBannerBody) }}
|
||||
</div>
|
||||
<template #actions>
|
||||
<ButtonStyled color="blue">
|
||||
<a href="https://modrinth.com/legal/cmp-info" target="_blank" class="w-fit !px-4">
|
||||
{{ formatMessage(analyticsStatCardMessages.monetizationBannerLearnMore) }}
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard
|
||||
v-for="card in statCards"
|
||||
:key="card.key"
|
||||
:label="card.label"
|
||||
:stat-label="card.statLabel"
|
||||
:stat-tooltip="card.statTooltip"
|
||||
:vs-prev-period-percent="card.vsPrevPeriodPercent"
|
||||
:icon="card.icon"
|
||||
:active="activeStat === card.key"
|
||||
:disabled="card.disabled"
|
||||
@click="setActiveStat(card.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, useFormatNumber, useVIntl } from '@modrinth/ui'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
import {
|
||||
type AnalyticsDashboardStat,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
|
||||
import { formatAnalyticsTableFullPlaytime } from '../analytics-table/analytics-table-formatting.ts'
|
||||
import StatCard from './StatCard.vue'
|
||||
|
||||
const MONETIZATION_BANNER_DISMISSED_KEY = 'analytics-monetization-banner-dismissed'
|
||||
|
||||
const {
|
||||
activeStat,
|
||||
setActiveStat,
|
||||
currentTotals,
|
||||
previousTotals,
|
||||
percentChanges,
|
||||
hasPreviousPeriodComparison,
|
||||
selectedBreakdowns,
|
||||
isAnalyticsDashboardStatRelevant,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const monetizationBannerDismissed = useLocalStorage(MONETIZATION_BANNER_DISMISSED_KEY, false)
|
||||
const showMonetizationBanner = computed(
|
||||
() => selectedBreakdowns.value.includes('monetization') && !monetizationBannerDismissed.value,
|
||||
)
|
||||
const MAX_PREVIOUS_PERIOD_PERCENT_DISPLAY = 1000
|
||||
|
||||
const compactNumberFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumSignificantDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
const underDollarRevenueFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
const preciseRevenueFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 5,
|
||||
maximumFractionDigits: 5,
|
||||
}),
|
||||
)
|
||||
|
||||
const tooltipRevenueFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
const underHourPlaytimeFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
function formatStatNumber(value: number): string {
|
||||
const rounded = Math.round(value)
|
||||
|
||||
if (Math.abs(rounded) >= 1000) {
|
||||
return compactNumberFormatter.value.format(rounded)
|
||||
}
|
||||
|
||||
return formatNumber(rounded)
|
||||
}
|
||||
|
||||
function formatFullStatNumber(value: number): string {
|
||||
return formatNumber(Math.round(value))
|
||||
}
|
||||
|
||||
function formatRevenueNumber(value: number): string {
|
||||
if (Math.abs(value) > 0 && Math.abs(value) < 1) {
|
||||
return underDollarRevenueFormatter.value.format(value)
|
||||
}
|
||||
|
||||
return formatStatNumber(value)
|
||||
}
|
||||
|
||||
function formatRevenueValue(value: number): string {
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatRevenueNumber(value),
|
||||
})
|
||||
}
|
||||
|
||||
function formatPreciseRevenueValue(value: number): string {
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value:
|
||||
Math.abs(value) < 1
|
||||
? preciseRevenueFormatter.value.format(value)
|
||||
: tooltipRevenueFormatter.value.format(value),
|
||||
})
|
||||
}
|
||||
|
||||
function formatPlaytimeTooltip(value: number): string {
|
||||
return formatAnalyticsTableFullPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function formatPlaytimeNumber(value: number): string {
|
||||
if (Math.abs(value) > 0 && Math.abs(value) < 1) {
|
||||
return underHourPlaytimeFormatter.value.format(value)
|
||||
}
|
||||
|
||||
return formatStatNumber(value)
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
if (rounded === 0) {
|
||||
return '0%'
|
||||
}
|
||||
|
||||
const signPrefix = rounded > 0 ? '+' : ''
|
||||
return `${signPrefix}${rounded.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatSignedStatNumber(value: number): string {
|
||||
const signPrefix = value > 0 ? '+' : ''
|
||||
return `${signPrefix}${formatStatNumber(value)}`
|
||||
}
|
||||
|
||||
function formatSignedRevenue(value: number): string {
|
||||
const signPrefix = value > 0 ? '+' : value < 0 ? '-' : ''
|
||||
return `${signPrefix}${formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatRevenueNumber(Math.abs(value)),
|
||||
})}`
|
||||
}
|
||||
|
||||
function formatSignedPlaytimeHours(value: number): string {
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
if (rounded === 0) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
if (Math.abs(rounded) >= 1000) {
|
||||
const signPrefix = rounded > 0 ? '+' : ''
|
||||
return `${signPrefix}${compactNumberFormatter.value.format(rounded)}`
|
||||
}
|
||||
|
||||
const signPrefix = rounded > 0 ? '+' : ''
|
||||
return `${signPrefix}${rounded.toFixed(1)}`
|
||||
}
|
||||
|
||||
function formatSignedPlaytime(value: number): string {
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: formatSignedPlaytimeHours(value / 3600),
|
||||
})
|
||||
}
|
||||
|
||||
function formatPreviousPeriodComparison(
|
||||
stat: AnalyticsDashboardStat,
|
||||
percentChange: number,
|
||||
currentValue: number,
|
||||
previousValue: number,
|
||||
): string | null {
|
||||
if (!hasPreviousPeriodComparison.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const delta = currentValue - previousValue
|
||||
if (previousValue === 0 && currentValue === 0) {
|
||||
return formatPercent(percentChange)
|
||||
}
|
||||
|
||||
if (previousValue !== 0 && Math.abs(percentChange) <= MAX_PREVIOUS_PERIOD_PERCENT_DISPLAY) {
|
||||
return formatPercent(percentChange)
|
||||
}
|
||||
|
||||
switch (stat) {
|
||||
case 'revenue':
|
||||
return formatSignedRevenue(delta)
|
||||
case 'playtime':
|
||||
return formatSignedPlaytime(delta)
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
return formatSignedStatNumber(delta)
|
||||
}
|
||||
}
|
||||
|
||||
function dismissMonetizationBanner() {
|
||||
monetizationBannerDismissed.value = true
|
||||
}
|
||||
|
||||
const statCards = computed<
|
||||
{
|
||||
key: AnalyticsDashboardStat
|
||||
label: string
|
||||
statLabel: string
|
||||
statTooltip?: string
|
||||
vsPrevPeriodPercent: string | null
|
||||
icon: string
|
||||
disabled: boolean
|
||||
}[]
|
||||
>(() => [
|
||||
{
|
||||
key: 'views',
|
||||
label: formatAnalyticsStatLabel('views', formatMessage),
|
||||
statLabel: formatStatNumber(currentTotals.value.views),
|
||||
statTooltip: formatFullStatNumber(currentTotals.value.views),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodComparison(
|
||||
'views',
|
||||
percentChanges.value.views,
|
||||
currentTotals.value.views,
|
||||
previousTotals.value.views,
|
||||
),
|
||||
icon: 'eye',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
|
||||
},
|
||||
{
|
||||
key: 'downloads',
|
||||
label: formatAnalyticsStatLabel('downloads', formatMessage),
|
||||
statLabel: formatStatNumber(currentTotals.value.downloads),
|
||||
statTooltip: formatFullStatNumber(currentTotals.value.downloads),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodComparison(
|
||||
'downloads',
|
||||
percentChanges.value.downloads,
|
||||
currentTotals.value.downloads,
|
||||
previousTotals.value.downloads,
|
||||
),
|
||||
icon: 'download',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
label: formatAnalyticsStatLabel('revenue', formatMessage),
|
||||
statLabel: formatRevenueValue(currentTotals.value.revenue),
|
||||
statTooltip: formatPreciseRevenueValue(currentTotals.value.revenue),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodComparison(
|
||||
'revenue',
|
||||
percentChanges.value.revenue,
|
||||
currentTotals.value.revenue,
|
||||
previousTotals.value.revenue,
|
||||
),
|
||||
icon: 'dollar',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
|
||||
},
|
||||
{
|
||||
key: 'playtime',
|
||||
label: formatAnalyticsStatLabel('playtime', formatMessage),
|
||||
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: formatPlaytimeNumber(currentTotals.value.playtime / 3600),
|
||||
}),
|
||||
statTooltip: formatPlaytimeTooltip(currentTotals.value.playtime),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodComparison(
|
||||
'playtime',
|
||||
percentChanges.value.playtime,
|
||||
currentTotals.value.playtime,
|
||||
previousTotals.value.playtime,
|
||||
),
|
||||
icon: 'clock',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
areSelectedFiltersEqual,
|
||||
areStringArraysEqual,
|
||||
buildAnalyticsQueryBuilderRouteQuery,
|
||||
getAnalyticsBreakdownPresetsForProjectSelection,
|
||||
hasAnalyticsQueryBuilderRouteChange,
|
||||
readAnalyticsGraphState,
|
||||
readAnalyticsQueryBuilderState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGraphViewMode,
|
||||
AnalyticsGroupByPreset,
|
||||
AnalyticsLastTimeframeUnit,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
AnalyticsSelectedFilters,
|
||||
AnalyticsTimeframeMode,
|
||||
AnalyticsTimeframePreset,
|
||||
} from '~/providers/analytics/analytics-types'
|
||||
|
||||
export type AnalyticsQueryBuilderRouteNavigationMode = 'push' | 'replace'
|
||||
|
||||
export interface AnalyticsQueryBuilderRefs {
|
||||
selectedProjectIds: Ref<string[]>
|
||||
selectedTimeframeMode: Ref<AnalyticsTimeframeMode>
|
||||
selectedTimeframe: Ref<AnalyticsTimeframePreset>
|
||||
selectedLastTimeframeAmount: Ref<number>
|
||||
selectedLastTimeframeUnit: Ref<AnalyticsLastTimeframeUnit>
|
||||
selectedCustomTimeframeStartDate: Ref<string>
|
||||
selectedCustomTimeframeEndDate: Ref<string>
|
||||
selectedGroupBy: Ref<AnalyticsGroupByPreset>
|
||||
selectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
|
||||
selectedFilters: Ref<AnalyticsSelectedFilters>
|
||||
}
|
||||
|
||||
export interface AnalyticsGraphRefs {
|
||||
activeStat: Ref<AnalyticsDashboardStat>
|
||||
activeGraphViewMode: Ref<AnalyticsGraphViewMode>
|
||||
isRatioMode: Ref<boolean>
|
||||
showChartEvents: Ref<boolean>
|
||||
showProjectEvents: Ref<boolean>
|
||||
showPreviousPeriod: Ref<boolean>
|
||||
hiddenGraphDatasetIds: Ref<string[]>
|
||||
hasExplicitGraphDatasetSelection: Ref<boolean>
|
||||
selectedGraphDatasetIds: Ref<string[]>
|
||||
}
|
||||
|
||||
export interface UseAnalyticsRouteSyncOptions {
|
||||
queryBuilder: AnalyticsQueryBuilderRefs
|
||||
graph: AnalyticsGraphRefs
|
||||
availableProjectIds: Ref<string[]>
|
||||
defaultProjectIds: Ref<string[]>
|
||||
sanitizeSelectedFilters: (
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
filters: AnalyticsSelectedFilters,
|
||||
) => AnalyticsSelectedFilters
|
||||
}
|
||||
|
||||
export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
|
||||
const { queryBuilder, graph, availableProjectIds, defaultProjectIds, sanitizeSelectedFilters } =
|
||||
options
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
let nextAnalyticsRouteNavigationMode: AnalyticsQueryBuilderRouteNavigationMode = 'replace'
|
||||
|
||||
function replaceNextAnalyticsRouteNavigation() {
|
||||
nextAnalyticsRouteNavigationMode = 'replace'
|
||||
}
|
||||
|
||||
function consumeAnalyticsRouteNavigationMode(): AnalyticsQueryBuilderRouteNavigationMode {
|
||||
const navigationMode = nextAnalyticsRouteNavigationMode
|
||||
nextAnalyticsRouteNavigationMode = 'push'
|
||||
return navigationMode
|
||||
}
|
||||
|
||||
function getSelectedAnalyticsQueryBuilderState() {
|
||||
return {
|
||||
selectedProjectIds: queryBuilder.selectedProjectIds.value,
|
||||
selectedTimeframeMode: queryBuilder.selectedTimeframeMode.value,
|
||||
selectedTimeframe: queryBuilder.selectedTimeframe.value,
|
||||
selectedLastTimeframeAmount: queryBuilder.selectedLastTimeframeAmount.value,
|
||||
selectedLastTimeframeUnit: queryBuilder.selectedLastTimeframeUnit.value,
|
||||
selectedCustomTimeframeStartDate: queryBuilder.selectedCustomTimeframeStartDate.value,
|
||||
selectedCustomTimeframeEndDate: queryBuilder.selectedCustomTimeframeEndDate.value,
|
||||
selectedGroupBy: queryBuilder.selectedGroupBy.value,
|
||||
selectedBreakdowns: queryBuilder.selectedBreakdowns.value,
|
||||
selectedFilters: queryBuilder.selectedFilters.value,
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedAnalyticsGraphState() {
|
||||
return {
|
||||
activeStat: graph.activeStat.value,
|
||||
activeGraphViewMode: graph.activeGraphViewMode.value,
|
||||
isRatioMode: graph.isRatioMode.value,
|
||||
showChartEvents: graph.showChartEvents.value,
|
||||
showProjectEvents: graph.showProjectEvents.value,
|
||||
showPreviousPeriod: graph.showPreviousPeriod.value,
|
||||
hiddenGraphDatasetIds: graph.hiddenGraphDatasetIds.value,
|
||||
selectedGraphDatasetIds: graph.hasExplicitGraphDatasetSelection.value
|
||||
? graph.selectedGraphDatasetIds.value
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAnalyticsRouteQuery(navigationMode: AnalyticsQueryBuilderRouteNavigationMode) {
|
||||
if (import.meta.server) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRouteQuery = buildAnalyticsQueryBuilderRouteQuery(
|
||||
route.query,
|
||||
getSelectedAnalyticsQueryBuilderState(),
|
||||
availableProjectIds.value,
|
||||
getSelectedAnalyticsGraphState(),
|
||||
defaultProjectIds.value,
|
||||
)
|
||||
|
||||
const hasAnalyticsQueryChange = hasAnalyticsQueryBuilderRouteChange(route.query, nextRouteQuery)
|
||||
|
||||
if (!hasAnalyticsQueryChange) return
|
||||
|
||||
if (navigationMode === 'replace') {
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextRouteQuery,
|
||||
})
|
||||
} else {
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: nextRouteQuery,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function syncQueryBuilderRouteQuery() {
|
||||
syncAnalyticsRouteQuery(consumeAnalyticsRouteNavigationMode())
|
||||
}
|
||||
|
||||
function syncGraphRouteQuery() {
|
||||
syncAnalyticsRouteQuery('replace')
|
||||
}
|
||||
|
||||
function applyRouteQueryToState(nextQuery: LocationQuery) {
|
||||
const nextQueryState = readAnalyticsQueryBuilderState(
|
||||
nextQuery,
|
||||
availableProjectIds.value,
|
||||
defaultProjectIds.value,
|
||||
)
|
||||
const availableProjectIdSet = new Set(availableProjectIds.value)
|
||||
const nextSelectedProjectIds = nextQueryState.selectedProjectIds.filter((projectId) =>
|
||||
availableProjectIdSet.has(projectId),
|
||||
)
|
||||
const nextGraphState = readAnalyticsGraphState(nextQuery, nextSelectedProjectIds)
|
||||
const nextSelectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
nextQueryState.selectedBreakdowns,
|
||||
nextSelectedProjectIds,
|
||||
)
|
||||
const nextSelectedFilters = sanitizeSelectedFilters(
|
||||
nextSelectedBreakdowns,
|
||||
nextQueryState.selectedFilters,
|
||||
)
|
||||
const shouldUpdateSelectedProjectIds = !areStringArraysEqual(
|
||||
queryBuilder.selectedProjectIds.value,
|
||||
nextSelectedProjectIds,
|
||||
)
|
||||
const shouldUpdateSelectedTimeframeMode =
|
||||
queryBuilder.selectedTimeframeMode.value !== nextQueryState.selectedTimeframeMode
|
||||
const shouldUpdateSelectedTimeframe =
|
||||
queryBuilder.selectedTimeframe.value !== nextQueryState.selectedTimeframe
|
||||
const shouldUpdateSelectedLastTimeframeAmount =
|
||||
queryBuilder.selectedLastTimeframeAmount.value !== nextQueryState.selectedLastTimeframeAmount
|
||||
const shouldUpdateSelectedLastTimeframeUnit =
|
||||
queryBuilder.selectedLastTimeframeUnit.value !== nextQueryState.selectedLastTimeframeUnit
|
||||
const shouldUpdateSelectedCustomTimeframeStartDate =
|
||||
queryBuilder.selectedCustomTimeframeStartDate.value !==
|
||||
nextQueryState.selectedCustomTimeframeStartDate
|
||||
const shouldUpdateSelectedCustomTimeframeEndDate =
|
||||
queryBuilder.selectedCustomTimeframeEndDate.value !==
|
||||
nextQueryState.selectedCustomTimeframeEndDate
|
||||
const shouldUpdateSelectedGroupBy =
|
||||
queryBuilder.selectedGroupBy.value !== nextQueryState.selectedGroupBy
|
||||
const shouldUpdateSelectedBreakdowns = !areStringArraysEqual(
|
||||
queryBuilder.selectedBreakdowns.value,
|
||||
nextSelectedBreakdowns,
|
||||
)
|
||||
const shouldUpdateSelectedFilters = !areSelectedFiltersEqual(
|
||||
queryBuilder.selectedFilters.value,
|
||||
nextSelectedFilters,
|
||||
)
|
||||
const shouldUpdateActiveStat = graph.activeStat.value !== nextGraphState.activeStat
|
||||
const shouldUpdateActiveGraphViewMode =
|
||||
graph.activeGraphViewMode.value !== nextGraphState.activeGraphViewMode
|
||||
const shouldUpdateIsRatioMode = graph.isRatioMode.value !== nextGraphState.isRatioMode
|
||||
const shouldUpdateShowChartEvents =
|
||||
graph.showChartEvents.value !== nextGraphState.showChartEvents
|
||||
const shouldUpdateShowProjectEvents =
|
||||
graph.showProjectEvents.value !== nextGraphState.showProjectEvents
|
||||
const shouldUpdateShowPreviousPeriod =
|
||||
graph.showPreviousPeriod.value !== nextGraphState.showPreviousPeriod
|
||||
const shouldUpdateHiddenGraphDatasetIds = !areStringArraysEqual(
|
||||
graph.hiddenGraphDatasetIds.value,
|
||||
nextGraphState.hiddenGraphDatasetIds,
|
||||
)
|
||||
const nextHasExplicitGraphDatasetSelection = nextGraphState.selectedGraphDatasetIds !== null
|
||||
const nextSelectedGraphDatasetIds = nextGraphState.selectedGraphDatasetIds ?? []
|
||||
const shouldUpdateHasExplicitGraphDatasetSelection =
|
||||
graph.hasExplicitGraphDatasetSelection.value !== nextHasExplicitGraphDatasetSelection
|
||||
const shouldUpdateSelectedGraphDatasetIds =
|
||||
(nextHasExplicitGraphDatasetSelection || graph.hasExplicitGraphDatasetSelection.value) &&
|
||||
!areStringArraysEqual(graph.selectedGraphDatasetIds.value, nextSelectedGraphDatasetIds)
|
||||
const hasRouteStateUpdate =
|
||||
shouldUpdateSelectedProjectIds ||
|
||||
shouldUpdateSelectedTimeframeMode ||
|
||||
shouldUpdateSelectedTimeframe ||
|
||||
shouldUpdateSelectedLastTimeframeAmount ||
|
||||
shouldUpdateSelectedLastTimeframeUnit ||
|
||||
shouldUpdateSelectedCustomTimeframeStartDate ||
|
||||
shouldUpdateSelectedCustomTimeframeEndDate ||
|
||||
shouldUpdateSelectedGroupBy ||
|
||||
shouldUpdateSelectedBreakdowns ||
|
||||
shouldUpdateSelectedFilters ||
|
||||
shouldUpdateActiveStat ||
|
||||
shouldUpdateActiveGraphViewMode ||
|
||||
shouldUpdateIsRatioMode ||
|
||||
shouldUpdateShowChartEvents ||
|
||||
shouldUpdateShowProjectEvents ||
|
||||
shouldUpdateShowPreviousPeriod ||
|
||||
shouldUpdateHiddenGraphDatasetIds ||
|
||||
shouldUpdateHasExplicitGraphDatasetSelection ||
|
||||
shouldUpdateSelectedGraphDatasetIds
|
||||
|
||||
if (hasRouteStateUpdate) {
|
||||
replaceNextAnalyticsRouteNavigation()
|
||||
}
|
||||
|
||||
if (shouldUpdateSelectedProjectIds) {
|
||||
queryBuilder.selectedProjectIds.value = nextSelectedProjectIds
|
||||
}
|
||||
if (shouldUpdateSelectedTimeframeMode) {
|
||||
queryBuilder.selectedTimeframeMode.value = nextQueryState.selectedTimeframeMode
|
||||
}
|
||||
if (shouldUpdateSelectedTimeframe) {
|
||||
queryBuilder.selectedTimeframe.value = nextQueryState.selectedTimeframe
|
||||
}
|
||||
if (shouldUpdateSelectedLastTimeframeAmount) {
|
||||
queryBuilder.selectedLastTimeframeAmount.value = nextQueryState.selectedLastTimeframeAmount
|
||||
}
|
||||
if (shouldUpdateSelectedLastTimeframeUnit) {
|
||||
queryBuilder.selectedLastTimeframeUnit.value = nextQueryState.selectedLastTimeframeUnit
|
||||
}
|
||||
if (shouldUpdateSelectedCustomTimeframeStartDate) {
|
||||
queryBuilder.selectedCustomTimeframeStartDate.value =
|
||||
nextQueryState.selectedCustomTimeframeStartDate
|
||||
}
|
||||
if (shouldUpdateSelectedCustomTimeframeEndDate) {
|
||||
queryBuilder.selectedCustomTimeframeEndDate.value =
|
||||
nextQueryState.selectedCustomTimeframeEndDate
|
||||
}
|
||||
if (shouldUpdateSelectedGroupBy) {
|
||||
queryBuilder.selectedGroupBy.value = nextQueryState.selectedGroupBy
|
||||
}
|
||||
if (shouldUpdateSelectedBreakdowns) {
|
||||
queryBuilder.selectedBreakdowns.value = nextSelectedBreakdowns
|
||||
}
|
||||
if (shouldUpdateSelectedFilters) {
|
||||
queryBuilder.selectedFilters.value = nextSelectedFilters
|
||||
}
|
||||
if (shouldUpdateActiveStat) {
|
||||
graph.activeStat.value = nextGraphState.activeStat
|
||||
}
|
||||
if (shouldUpdateActiveGraphViewMode) {
|
||||
graph.activeGraphViewMode.value = nextGraphState.activeGraphViewMode
|
||||
}
|
||||
if (shouldUpdateIsRatioMode) {
|
||||
graph.isRatioMode.value = nextGraphState.isRatioMode
|
||||
}
|
||||
if (shouldUpdateShowChartEvents) {
|
||||
graph.showChartEvents.value = nextGraphState.showChartEvents
|
||||
}
|
||||
if (shouldUpdateShowProjectEvents) {
|
||||
graph.showProjectEvents.value = nextGraphState.showProjectEvents
|
||||
}
|
||||
if (shouldUpdateShowPreviousPeriod) {
|
||||
graph.showPreviousPeriod.value = nextGraphState.showPreviousPeriod
|
||||
}
|
||||
if (shouldUpdateHiddenGraphDatasetIds) {
|
||||
graph.hiddenGraphDatasetIds.value = nextGraphState.hiddenGraphDatasetIds
|
||||
}
|
||||
if (shouldUpdateHasExplicitGraphDatasetSelection) {
|
||||
graph.hasExplicitGraphDatasetSelection.value = nextHasExplicitGraphDatasetSelection
|
||||
}
|
||||
if (shouldUpdateSelectedGraphDatasetIds) {
|
||||
graph.selectedGraphDatasetIds.value = nextSelectedGraphDatasetIds
|
||||
}
|
||||
|
||||
if (!hasRouteStateUpdate) {
|
||||
syncAnalyticsRouteQuery('replace')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
replaceNextAnalyticsRouteNavigation,
|
||||
syncQueryBuilderRouteQuery,
|
||||
syncGraphRouteQuery,
|
||||
applyRouteQueryToState,
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export default {
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0.25rem solid #ea80ff;
|
||||
outline: 0.25rem solid var(--color-focus-ring);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="warning" width="550px">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-extrabold text-contrast">Transfer</span>
|
||||
<Avatar :src="organization.icon_url" :alt="organization.name" size="xs" />
|
||||
<span class="text-lg font-extrabold text-contrast">{{ organization.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition type="warning" header="Beware of scams">
|
||||
Do not transfer organizations to buyers. This is a common scam and against our TOS. If you
|
||||
encounter a buyer, please
|
||||
<a
|
||||
href="https://support.modrinth.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline"
|
||||
>contact support</a
|
||||
>.
|
||||
</Admonition>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto_1fr] items-center justify-center gap-6 rounded-2xl bg-surface-2 p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :src="currentOwner.avatar_url" :alt="currentOwner.username" size="xs" circle />
|
||||
<div class="flex flex-col items-start justify-start gap-1">
|
||||
<span class="font-medium text-contrast">{{ currentOwner.username }}</span>
|
||||
<span class="text-sm text-secondary">{{ currentOwner.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<RightArrowIcon class="h-6 w-6 text-secondary" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :src="transferTo.avatar_url" :alt="transferTo.username" size="xs" circle />
|
||||
<div class="flex flex-col items-start justify-start gap-1">
|
||||
<span class="font-medium text-contrast">{{ transferTo.username }} </span>
|
||||
<span class="text-sm text-secondary">{{ transferTo.role }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="m-0 flex flex-col gap-1 pl-6 text-secondary">
|
||||
<li>You will immediately lose owner access to this organization</li>
|
||||
<li>The new owner can modify or delete the organization and all its projects</li>
|
||||
<li>This action cannot be undone</li>
|
||||
</ul>
|
||||
<div>
|
||||
<p class="m-0 mb-2">
|
||||
To confirm this transfer, type
|
||||
<span class="font-bold text-contrast">{{ organization.name }}</span> below
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="confirmationText"
|
||||
:placeholder="`Enter ${organization.name}`"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="!isConfirmEnabled" @click="onConfirmClick">
|
||||
<TransferIcon />
|
||||
Transfer ownership
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, TransferIcon, XIcon } from '@modrinth/assets'
|
||||
import { Admonition, Avatar, ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
organization: { name: string; icon_url: string | null }
|
||||
currentOwner: { avatar_url: string | null; username: string; role: string }
|
||||
transferTo: { avatar_url: string | null; username: string; role: string }
|
||||
onConfirm: () => void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const confirmationText = ref('')
|
||||
|
||||
const isConfirmEnabled = computed(
|
||||
() =>
|
||||
!!props.organization.name &&
|
||||
confirmationText.value.toLowerCase().trim() === props.organization.name.toLowerCase().trim(),
|
||||
)
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
confirmationText.value = ''
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function onConfirmClick() {
|
||||
hide()
|
||||
props.onConfirm()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="warning" width="550px">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-extrabold text-contrast">Transfer</span>
|
||||
<Avatar :src="project.icon_url" :alt="project.name" size="xs" />
|
||||
<span class="text-lg font-extrabold text-contrast">{{ project.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition type="warning" header="Beware of scams">
|
||||
Do not transfer projects to buyers. This is a common scam and against our TOS. If you
|
||||
encounter a buyer, please
|
||||
<a
|
||||
href="https://support.modrinth.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline"
|
||||
>contact support</a
|
||||
>.
|
||||
</Admonition>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto_1fr] items-center justify-center gap-6 rounded-2xl bg-surface-2 p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :src="currentOwner.avatar_url" :alt="currentOwner.username" size="xs" circle />
|
||||
<div class="flex flex-col items-start justify-start gap-1">
|
||||
<span class="font-medium text-contrast">{{ currentOwner.username }}</span>
|
||||
<span class="text-sm text-secondary">{{ currentOwner.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<RightArrowIcon class="h-6 w-6 text-secondary" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="transferTo.avatar_url"
|
||||
:alt="transferTo.username || transferTo.name"
|
||||
size="xs"
|
||||
circle
|
||||
/>
|
||||
<div class="flex flex-col items-start justify-start gap-1">
|
||||
<span class="font-medium text-contrast">
|
||||
{{ transferTo.username || transferTo.name }}
|
||||
</span>
|
||||
<span class="text-sm text-secondary">{{ transferTo.role || 'Member' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="m-0 flex flex-col gap-1 pl-6 text-secondary">
|
||||
<li>You will immediately lose owner access to this project</li>
|
||||
<li>The new owner can modify or delete the project at any time</li>
|
||||
<li>This action cannot be undone</li>
|
||||
</ul>
|
||||
<div>
|
||||
<p class="m-0 mb-2">
|
||||
To confirm this transfer, type
|
||||
<span class="font-bold text-contrast">{{ project.name }}</span> below
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="confirmationText"
|
||||
:placeholder="`Enter ${project.name}`"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="!isConfirmEnabled" @click="onConfirmClick">
|
||||
<TransferIcon />
|
||||
Transfer ownership
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, TransferIcon, XIcon } from '@modrinth/assets'
|
||||
import { Admonition, Avatar, ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
project: { name: string; icon_url: string | null }
|
||||
currentOwner: { avatar_url: string | null; username: string; role: string }
|
||||
transferTo: {
|
||||
avatar_url?: string | null
|
||||
username?: string
|
||||
name?: string
|
||||
role?: string
|
||||
}
|
||||
onConfirm: () => void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const confirmationText = ref('')
|
||||
|
||||
const isConfirmEnabled = computed(
|
||||
() =>
|
||||
!!props.project.name &&
|
||||
confirmationText.value.toLowerCase().trim() === props.project.name.toLowerCase().trim(),
|
||||
)
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
confirmationText.value = ''
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function onConfirmClick() {
|
||||
hide()
|
||||
props.onConfirm()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -17,74 +17,60 @@
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { fileIsValid } from '~/helpers/fileUtils.js'
|
||||
<script setup lang="ts">
|
||||
import { useFormatBytes } from '@modrinth/ui'
|
||||
import { fileIsValid } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
prompt: {
|
||||
type: String,
|
||||
default: 'Select file',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
prompt?: string
|
||||
multiple?: boolean
|
||||
accept?: string
|
||||
/**
|
||||
* The max file size in bytes
|
||||
*/
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
shouldAlwaysReset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
longStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxSize?: number | null
|
||||
showIcon?: boolean
|
||||
shouldAlwaysReset?: boolean
|
||||
longStyle?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
prompt: 'Select file',
|
||||
multiple: false,
|
||||
showIcon: true,
|
||||
shouldAlwaysReset: false,
|
||||
longStyle: false,
|
||||
disabled: false,
|
||||
},
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addFiles(files, shouldNotReset) {
|
||||
if (!shouldNotReset || this.shouldAlwaysReset) {
|
||||
this.files = files
|
||||
}
|
||||
)
|
||||
|
||||
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
|
||||
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
|
||||
const emit = defineEmits<{ change: [files: File[]] }>()
|
||||
|
||||
if (this.files.length > 0) {
|
||||
this.$emit('change', this.files)
|
||||
}
|
||||
},
|
||||
handleDrop(e) {
|
||||
this.addFiles(e.dataTransfer.files)
|
||||
},
|
||||
handleChange(e) {
|
||||
this.addFiles(e.target.files)
|
||||
},
|
||||
},
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const files = ref<File[]>([])
|
||||
|
||||
function addFiles(incoming: FileList, shouldNotReset = false) {
|
||||
if (!shouldNotReset || props.shouldAlwaysReset) {
|
||||
files.value = Array.from(incoming)
|
||||
}
|
||||
const validationOptions = { maxSize: props.maxSize, alertOnInvalid: true }
|
||||
files.value = files.value.filter((file) => fileIsValid(file, validationOptions, formatBytes))
|
||||
if (files.value.length > 0) {
|
||||
emit('change', files.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
addFiles(e.dataTransfer!.files)
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files) return
|
||||
addFiles(input.files)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
<div class="modal-body">
|
||||
<div v-if="header" class="header">
|
||||
<strong>{{ header }}</strong>
|
||||
<button class="iconified-button icon-only transparent" @click="hide">
|
||||
<XIcon />
|
||||
</button>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
@@ -27,9 +29,11 @@
|
||||
|
||||
<script>
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ButtonStyled,
|
||||
XIcon,
|
||||
},
|
||||
props: {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { BlueskyIcon, DiscordIcon, GithubIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
|
||||
import {
|
||||
BlueskyIcon,
|
||||
DiscordIcon,
|
||||
GithubIcon,
|
||||
MastodonIcon,
|
||||
ToggleRightIcon,
|
||||
TwitterIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
AutoLink,
|
||||
ButtonStyled,
|
||||
@@ -10,6 +17,7 @@ import {
|
||||
type MessageDescriptor,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { commonSettingsMessages } from '@modrinth/ui/src/utils/common-messages.js'
|
||||
|
||||
import TextLogo from '~/components/brand/TextLogo.vue'
|
||||
|
||||
@@ -200,6 +208,7 @@ const developerModeCounter = ref(0)
|
||||
const state = useGeneratedState()
|
||||
|
||||
function developerModeIncrement() {
|
||||
developerModeCounter.value++
|
||||
if (developerModeCounter.value >= 5) {
|
||||
flags.value.developerMode = !flags.value.developerMode
|
||||
developerModeCounter.value = 0
|
||||
@@ -217,16 +226,12 @@ function developerModeIncrement() {
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
developerModeCounter.value++
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
class="footer-brand-background experimental-styles-within border-0 border-t-[1px] border-solid"
|
||||
>
|
||||
<footer class="footer-brand-background border-0 border-t-[1px] border-solid">
|
||||
<div class="mx-auto flex max-w-screen-xl flex-col gap-6 p-6 pb-20 sm:px-12 md:py-12">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 text-primary md:grid-cols-[1fr_2fr] lg:grid-cols-[auto_auto_auto_auto_auto]"
|
||||
@@ -236,11 +241,21 @@ function developerModeIncrement() {
|
||||
role="region"
|
||||
:aria-label="formatMessage(messages.modrinthInformation)"
|
||||
>
|
||||
<TextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<TextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<ButtonStyled v-if="flags.developerMode" circular type="transparent" color="brand">
|
||||
<nuxt-link
|
||||
v-tooltip="formatMessage(commonSettingsMessages.featureFlags)"
|
||||
to="/settings/flags"
|
||||
>
|
||||
<ToggleRightIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
|
||||
<ButtonStyled
|
||||
v-for="(social, index) in socialLinks"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<nav :aria-label="ariaLabel" class="w-full">
|
||||
<ul
|
||||
class="card-shadow m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl bg-bg-raised p-4"
|
||||
class="card-shadow m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
|
||||
:class="{ 'pt-3': filteredItems?.[0]?.type === 'heading' }"
|
||||
>
|
||||
<slot v-if="hasSlotContent" />
|
||||
|
||||
@@ -19,7 +20,7 @@
|
||||
<NuxtLink
|
||||
v-else-if="item.link ?? item.to"
|
||||
:to="(item.link ?? item.to) as string"
|
||||
class="nav-item inline-flex w-full cursor-pointer items-center gap-2 text-nowrap rounded-xl border-none bg-transparent px-4 py-2.5 text-left text-base font-semibold leading-tight text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97]"
|
||||
class="nav-item inline-flex w-full cursor-pointer items-center gap-2 rounded-xl border-none bg-transparent px-4 py-2.5 text-left text-base font-semibold leading-tight text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97]"
|
||||
:class="{ 'is-active': isActive(item as NavStackLinkItem) }"
|
||||
>
|
||||
<component
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
:class="{ 'card-shadow': mode === 'navigation' }"
|
||||
>
|
||||
<template v-if="mode === 'navigation'">
|
||||
<NuxtLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@mouseenter="link.onHover?.()"
|
||||
@focus="link.onHover?.()"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@click="emit('tabClick', index, link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Animated slider background -->
|
||||
<div
|
||||
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
|
||||
:class="[
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
||||
{ 'navtabs-transition': transitionsEnabled },
|
||||
]"
|
||||
:style="sliderStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const route = useNativeRoute()
|
||||
|
||||
interface Tab {
|
||||
label: string
|
||||
href: string
|
||||
shown?: boolean
|
||||
icon?: Component
|
||||
subpages?: string[]
|
||||
onHover?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
links: Tab[]
|
||||
query?: string
|
||||
mode?: 'navigation' | 'local'
|
||||
activeIndex?: number
|
||||
}>(),
|
||||
{
|
||||
mode: 'navigation',
|
||||
query: undefined,
|
||||
activeIndex: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
tabClick: [index: number, tab: Tab]
|
||||
}>()
|
||||
|
||||
// DOM refs
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const tabLinkElements = ref<HTMLElement[]>()
|
||||
|
||||
// Slider pos state
|
||||
const sliderLeft = ref(4)
|
||||
const sliderTop = ref(4)
|
||||
const sliderRight = ref(4)
|
||||
const sliderBottom = ref(4)
|
||||
|
||||
// active tab state
|
||||
const currentActiveIndex = ref(-1)
|
||||
const subpageSelected = ref(false)
|
||||
|
||||
// SSR state
|
||||
const sliderReady = ref(false) // Slider is positioned and should be visible
|
||||
const transitionsEnabled = ref(false) // CSS transitions should apply (after first paint)
|
||||
|
||||
const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true))
|
||||
|
||||
const sliderStyle = computed(() => ({
|
||||
left: `${sliderLeft.value}px`,
|
||||
top: `${sliderTop.value}px`,
|
||||
right: `${sliderRight.value}px`,
|
||||
bottom: `${sliderBottom.value}px`,
|
||||
opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0,
|
||||
}))
|
||||
|
||||
const isActiveAndNotSubpage = computed(
|
||||
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
|
||||
)
|
||||
|
||||
function getSSRFallbackClasses(index: number) {
|
||||
if (sliderReady.value) return {}
|
||||
if (currentActiveIndex.value !== index) return {}
|
||||
|
||||
return {
|
||||
'rounded-full': true,
|
||||
'bg-button-bgSelected': !subpageSelected.value,
|
||||
'bg-button-bg': subpageSelected.value,
|
||||
}
|
||||
}
|
||||
|
||||
function getIconClasses(index: number) {
|
||||
return {
|
||||
'text-button-textSelected': isActiveAndNotSubpage.value(index),
|
||||
'text-secondary': !isActiveAndNotSubpage.value(index),
|
||||
}
|
||||
}
|
||||
|
||||
function getLabelClasses(index: number) {
|
||||
return {
|
||||
'text-button-textSelected': isActiveAndNotSubpage.value(index),
|
||||
'text-contrast': !isActiveAndNotSubpage.value(index),
|
||||
}
|
||||
}
|
||||
|
||||
function computeActiveIndex(): { index: number; isSubpage: boolean } {
|
||||
if (props.mode === 'local' && props.activeIndex !== undefined) {
|
||||
return {
|
||||
index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
|
||||
isSubpage: false,
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||
const link = filteredLinks.value[i]
|
||||
const decodedPath = decodeURIComponent(route.path)
|
||||
|
||||
// Query-based matching
|
||||
if (props.query) {
|
||||
const queryValue = route.query[props.query]
|
||||
if (queryValue === link.href || (!queryValue && !link.href)) {
|
||||
return { index: i, isSubpage: false }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Exact path match
|
||||
if (decodedPath === link.href) {
|
||||
return { index: i, isSubpage: false }
|
||||
}
|
||||
|
||||
// Subpage match
|
||||
const isSubpageMatch =
|
||||
decodedPath.includes(link.href) ||
|
||||
link.subpages?.some((subpage) => decodedPath.includes(subpage))
|
||||
|
||||
if (isSubpageMatch) {
|
||||
return { index: i, isSubpage: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { index: -1, isSubpage: false }
|
||||
}
|
||||
|
||||
function getTabElement(index: number): HTMLElement | null {
|
||||
if (!tabLinkElements.value?.[index]) return null
|
||||
|
||||
// In navigation mode, elements are NuxtLinks with $el property
|
||||
// In local mode, elements are plain divs
|
||||
const element = tabLinkElements.value[index]
|
||||
return props.mode === 'navigation' ? (element as any).$el : element
|
||||
}
|
||||
|
||||
function positionSlider() {
|
||||
const el = getTabElement(currentActiveIndex.value)
|
||||
if (!el?.offsetParent) return
|
||||
|
||||
const parent = el.offsetParent as HTMLElement
|
||||
const newPosition = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
|
||||
|
||||
if (isInitialPosition) {
|
||||
// Initial positioning: set position instantly, no animation
|
||||
sliderLeft.value = newPosition.left
|
||||
sliderRight.value = newPosition.right
|
||||
sliderTop.value = newPosition.top
|
||||
sliderBottom.value = newPosition.bottom
|
||||
|
||||
sliderReady.value = true
|
||||
|
||||
// enable transitions after slider is painted, so future changes animate
|
||||
requestAnimationFrame(() => {
|
||||
transitionsEnabled.value = true
|
||||
})
|
||||
} else {
|
||||
animateSliderTo(newPosition)
|
||||
}
|
||||
}
|
||||
|
||||
function animateSliderTo(newPosition: {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}) {
|
||||
const STAGGER_DELAY = 200
|
||||
|
||||
// Horizontal animation - lead with the direction of movement
|
||||
if (newPosition.left < sliderLeft.value) {
|
||||
sliderLeft.value = newPosition.left
|
||||
setTimeout(() => (sliderRight.value = newPosition.right), STAGGER_DELAY)
|
||||
} else {
|
||||
sliderRight.value = newPosition.right
|
||||
setTimeout(() => (sliderLeft.value = newPosition.left), STAGGER_DELAY)
|
||||
}
|
||||
|
||||
// Vertical animation - lead with the direction of movement
|
||||
if (newPosition.top < sliderTop.value) {
|
||||
sliderTop.value = newPosition.top
|
||||
setTimeout(() => (sliderBottom.value = newPosition.bottom), STAGGER_DELAY)
|
||||
} else {
|
||||
sliderBottom.value = newPosition.bottom
|
||||
setTimeout(() => (sliderTop.value = newPosition.top), STAGGER_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveTab() {
|
||||
const { index, isSubpage } = computeActiveIndex()
|
||||
currentActiveIndex.value = index
|
||||
subpageSelected.value = isSubpage
|
||||
|
||||
if (index !== -1) {
|
||||
nextTick(positionSlider)
|
||||
} else {
|
||||
sliderLeft.value = 0
|
||||
sliderRight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const initialActive = computeActiveIndex()
|
||||
currentActiveIndex.value = initialActive.index
|
||||
subpageSelected.value = initialActive.isSubpage
|
||||
|
||||
onMounted(updateActiveTab)
|
||||
|
||||
watch(
|
||||
() => [route.path, route.query],
|
||||
() => {
|
||||
if (props.mode === 'navigation') {
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.activeIndex,
|
||||
() => {
|
||||
if (props.mode === 'local') {
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(() => props.links, updateActiveTab, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
transition:
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, MailIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import { ButtonStyled, defineMessages, injectModrinthClient, useVIntl } from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useBaseFetch } from '~/composables/fetch.js'
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const auth = await useAuth()
|
||||
const messages = defineMessages({
|
||||
tooltipSubscribe: {
|
||||
id: 'ui.newsletter-button.tooltip',
|
||||
defaultMessage: 'Subscribe to the Modrinth newsletter',
|
||||
},
|
||||
subscribe: {
|
||||
id: 'ui.newsletter-button.subscribe',
|
||||
defaultMessage: 'Subscribe',
|
||||
},
|
||||
subscribed: {
|
||||
id: 'ui.newsletter-button.subscribed',
|
||||
defaultMessage: 'Subscribed!',
|
||||
},
|
||||
})
|
||||
|
||||
const auth = (await useAuth()) as unknown as {
|
||||
value: { user: { id: string; username: string; email: string; created: string } }
|
||||
}
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const showSubscriptionConfirmation = ref(false)
|
||||
const showSubscribeButton = useAsyncData(
|
||||
async () => {
|
||||
|
||||
const { data: showSubscribeButton, isSuccess } = useQuery({
|
||||
queryKey: computed(() => ['newsletter', 'subscribed', auth.value?.user?.id]),
|
||||
queryFn: async () => {
|
||||
if (auth.value?.user) {
|
||||
try {
|
||||
const { subscribed } = await useBaseFetch('auth/email/subscribe', {
|
||||
method: 'GET',
|
||||
})
|
||||
const { subscribed } = await client.labrinth.auth_internal.getNewsletterStatus()
|
||||
return !subscribed
|
||||
} catch {
|
||||
return true
|
||||
@@ -22,36 +42,31 @@ const showSubscribeButton = useAsyncData(
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ watch: [auth], server: false },
|
||||
)
|
||||
enabled: computed(() => !!auth.value?.user),
|
||||
})
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
await useBaseFetch('auth/email/subscribe', {
|
||||
method: 'POST',
|
||||
})
|
||||
await client.labrinth.auth_internal.subscribeNewsletter()
|
||||
showSubscriptionConfirmation.value = true
|
||||
} catch {
|
||||
// Ignored
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
showSubscriptionConfirmation.value = false
|
||||
showSubscribeButton.status.value = 'success'
|
||||
showSubscribeButton.data.value = false
|
||||
queryClient.setQueryData(['newsletter', 'subscribed', auth.value?.user?.id], false)
|
||||
}, 2500)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonStyled
|
||||
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
|
||||
color="brand"
|
||||
type="outlined"
|
||||
>
|
||||
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
||||
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
||||
<template v-else> <CheckIcon /> Subscribed! </template>
|
||||
<ButtonStyled v-if="isSuccess && showSubscribeButton" color="brand" type="outlined">
|
||||
<button v-tooltip="formatMessage(messages.tooltipSubscribe)" @click="subscribe">
|
||||
<template v-if="!showSubscriptionConfirmation">
|
||||
<MailIcon /> {{ formatMessage(messages.subscribe) }}
|
||||
</template>
|
||||
<template v-else> <CheckIcon /> {{ formatMessage(messages.subscribed) }} </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
@@ -1,321 +1,378 @@
|
||||
<template>
|
||||
<div
|
||||
class="notification"
|
||||
:class="{
|
||||
'has-body': hasBody,
|
||||
compact: compact,
|
||||
read: notification.read,
|
||||
}"
|
||||
:class="
|
||||
type === 'server_invite'
|
||||
? { read: notification.read }
|
||||
: {
|
||||
notification: true,
|
||||
'has-body': hasBody,
|
||||
compact: compact,
|
||||
read: notification.read,
|
||||
}
|
||||
"
|
||||
>
|
||||
<nuxt-link
|
||||
v-if="!type"
|
||||
:to="notification.link"
|
||||
class="notification__icon backed-svg"
|
||||
:class="{ raised: raised }"
|
||||
>
|
||||
<BellIcon />
|
||||
</nuxt-link>
|
||||
<DoubleIcon v-else class="notification__icon">
|
||||
<template #primary>
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
|
||||
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-else-if="organization"
|
||||
:to="`/organization/${organization.slug}`"
|
||||
tabindex="-1"
|
||||
>
|
||||
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
|
||||
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<Avatar v-else size="xs" :raised="raised" no-shadow />
|
||||
</template>
|
||||
<template #secondary>
|
||||
<ScaleIcon
|
||||
v-if="type === 'moderator_message' || type === 'status_change'"
|
||||
class="moderation-color"
|
||||
/>
|
||||
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
|
||||
<UserPlusIcon
|
||||
v-else-if="type === 'organization_invite' && organization"
|
||||
class="creator-color"
|
||||
/>
|
||||
<VersionIcon v-else-if="type === 'project_update' && project && version" />
|
||||
<BellIcon v-else />
|
||||
</template>
|
||||
</DoubleIcon>
|
||||
<div class="notification__title">
|
||||
<template v-if="type === 'project_update' && project && version">
|
||||
A project you follow,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
|
||||
, has been updated:
|
||||
</template>
|
||||
<template v-else-if="type === 'team_invite' && project">
|
||||
<nuxt-link
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="iconified-link title-link inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="inline-flex"
|
||||
/>
|
||||
<span class="space"> </span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span>
|
||||
has invited you to join
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }} </nuxt-link
|
||||
>.
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="type === 'organization_invite' && organization">
|
||||
<nuxt-link
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="iconified-link title-link inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="inline-flex"
|
||||
/>
|
||||
<span class="space"> </span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span>
|
||||
has invited you to join
|
||||
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
|
||||
{{ organization.name }} </nuxt-link
|
||||
>.
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="type === 'status_change' && project">
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
|
||||
has been
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
<template v-else>
|
||||
updated from
|
||||
<ProjectStatusBadge :status="notification.body.old_status" />
|
||||
to
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
by the moderators.
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator_message' && thread && project && !report">
|
||||
Your project,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
|
||||
, has received
|
||||
<template v-if="notification.grouped_notifs"> messages</template>
|
||||
<template v-else>a message</template>
|
||||
from the moderators.
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator_message' && thread && report">
|
||||
A moderator replied to your report of
|
||||
<template v-if="version">
|
||||
version
|
||||
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
|
||||
{{ version.name }}
|
||||
</nuxt-link>
|
||||
of project
|
||||
</template>
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
|
||||
{{ user.username }}
|
||||
</nuxt-link>
|
||||
.
|
||||
</template>
|
||||
<nuxt-link v-else :to="notification.link" class="title-link">
|
||||
<span v-html="renderString(notification.title)" />
|
||||
</nuxt-link>
|
||||
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
|
||||
</div>
|
||||
<div v-if="hasBody" class="notification__body">
|
||||
<ThreadSummary
|
||||
v-if="type === 'moderator_message' && thread"
|
||||
:thread="thread"
|
||||
:link="threadLink"
|
||||
:raised="raised"
|
||||
:messages="getMessages()"
|
||||
class="thread-summary"
|
||||
:auth="auth"
|
||||
/>
|
||||
<div v-else-if="type === 'project_update'" class="version-list">
|
||||
<template v-if="type === 'server_invite'">
|
||||
<div class="flex flex-col gap-4">
|
||||
<ModrinthServersIcon class="h-auto w-56 max-w-full text-[var(--color-heading)]" />
|
||||
<div
|
||||
v-for="notif in (notification.grouped_notifs
|
||||
? [notification, ...notification.grouped_notifs]
|
||||
: [notification]
|
||||
).filter((x) => x.extra_data.version)"
|
||||
:key="notif.id"
|
||||
class="version-link"
|
||||
class="flex flex-wrap items-center gap-x-1.5 gap-y-2 text-lg leading-tight text-[var(--color-heading)]"
|
||||
>
|
||||
<VersionIcon />
|
||||
<nuxt-link
|
||||
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
|
||||
class="text-link"
|
||||
v-if="invitedBy"
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="inline-flex items-center font-bold text-[var(--color-heading)] hover:underline"
|
||||
>
|
||||
{{ notif.extra_data.version.name }}
|
||||
</nuxt-link>
|
||||
<span class="version-info">
|
||||
for
|
||||
<Categories
|
||||
:categories="getLoaderCategories(notif.extra_data.version)"
|
||||
:type="notif.extra_data.project.project_type"
|
||||
class="categories"
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="mr-1.5 inline-flex"
|
||||
/>
|
||||
{{ $formatVersion(notif.extra_data.version.game_versions) }}
|
||||
<span
|
||||
v-tooltip="
|
||||
$dayjs(notif.extra_data.version.date_published).format('MMMM D, YYYY [at] h:mm A')
|
||||
"
|
||||
class="date"
|
||||
>
|
||||
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
|
||||
</span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span v-if="invitedBy">has invited you to manage</span>
|
||||
<span v-else>You have been invited to manage</span>
|
||||
<span
|
||||
><strong class="font-bold text-[var(--color-heading)]">{{
|
||||
notification.body.server_name
|
||||
}}</strong
|
||||
>.</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="!notification.read"
|
||||
class="flex flex-wrap items-center gap-3"
|
||||
:class="{ 'gap-2': compact }"
|
||||
>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="performActionByTitle(notification, 'Accept')">
|
||||
<CheckIcon />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="performActionByTitle(notification, 'Deny')">
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span
|
||||
v-if="notification.read"
|
||||
class="inline-flex items-center font-bold text-[var(--color-text)]"
|
||||
>
|
||||
<CheckCircleIcon /> Read
|
||||
</span>
|
||||
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex items-center">
|
||||
<CalendarIcon class="mr-1" /> Received
|
||||
{{ formatRelativeTime(notification.created) }}
|
||||
</span>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ notification.text }}
|
||||
</template>
|
||||
</div>
|
||||
<span class="notification__date">
|
||||
<span v-if="notification.read" class="read-badge inline-flex">
|
||||
<CheckCircleIcon /> Read
|
||||
</span>
|
||||
<span
|
||||
v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="inline-flex"
|
||||
</template>
|
||||
<template v-else>
|
||||
<nuxt-link
|
||||
v-if="!type"
|
||||
:to="notification.link"
|
||||
class="notification__icon backed-svg"
|
||||
:class="{ raised: raised }"
|
||||
>
|
||||
<CalendarIcon class="mr-1" /> Received
|
||||
{{ formatRelativeTime(notification.created) }}
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="compact" class="notification__actions">
|
||||
<template v-if="type === 'team_invite' || type === 'organization_invite'">
|
||||
<button
|
||||
v-tooltip="`Accept`"
|
||||
class="iconified-button square-button brand-button button-transparent"
|
||||
@click="
|
||||
() => {
|
||||
acceptTeamInvite(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="`Decline`"
|
||||
class="iconified-button square-button danger-button button-transparent"
|
||||
@click="
|
||||
() => {
|
||||
removeSelfFromTeam(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="!notification.read"
|
||||
v-tooltip="`Mark as read`"
|
||||
class="iconified-button square-button button-transparent"
|
||||
@click="read()"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="notification__actions">
|
||||
<div v-if="type !== null" class="input-group">
|
||||
<template
|
||||
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
|
||||
>
|
||||
<button
|
||||
class="iconified-button brand-button"
|
||||
@click="
|
||||
() => {
|
||||
acceptTeamInvite(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
<BellIcon />
|
||||
</nuxt-link>
|
||||
<DoubleIcon v-else class="notification__icon">
|
||||
<template #primary>
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
|
||||
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-else-if="organization"
|
||||
:to="`/organization/${organization.slug}`"
|
||||
tabindex="-1"
|
||||
>
|
||||
<CheckIcon />
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
class="iconified-button danger-button"
|
||||
@click="
|
||||
() => {
|
||||
removeSelfFromTeam(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
|
||||
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<Avatar v-else size="xs" :raised="raised" no-shadow />
|
||||
</template>
|
||||
<button
|
||||
v-else-if="!notification.read"
|
||||
class="iconified-button"
|
||||
:class="{ 'raised-button': raised }"
|
||||
@click="read()"
|
||||
>
|
||||
<CheckIcon />
|
||||
Mark as read
|
||||
</button>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
<div v-else class="input-group">
|
||||
<nuxt-link
|
||||
v-if="notification.link && notification.link !== '#'"
|
||||
class="iconified-button"
|
||||
:class="{ 'raised-button': raised }"
|
||||
:to="notification.link"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon />
|
||||
Open link
|
||||
<template #secondary>
|
||||
<ScaleIcon
|
||||
v-if="type === 'moderator_message' || type === 'status_change'"
|
||||
class="moderation-color"
|
||||
/>
|
||||
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
|
||||
<UserPlusIcon
|
||||
v-else-if="type === 'organization_invite' && organization"
|
||||
class="creator-color"
|
||||
/>
|
||||
<VersionIcon v-else-if="type === 'project_update' && project && version" />
|
||||
<BellIcon v-else />
|
||||
</template>
|
||||
</DoubleIcon>
|
||||
<div class="notification__title">
|
||||
<template v-if="type === 'project_update' && project && version">
|
||||
A project you follow,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{
|
||||
project.title
|
||||
}}</nuxt-link>
|
||||
, has been updated:
|
||||
</template>
|
||||
<template v-else-if="type === 'team_invite' && project">
|
||||
<nuxt-link
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="iconified-link title-link inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="inline-flex"
|
||||
/>
|
||||
<span class="space"> </span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span>
|
||||
has invited you to join
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }} </nuxt-link
|
||||
>.
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="type === 'organization_invite' && organization">
|
||||
<nuxt-link
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="iconified-link title-link inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="inline-flex"
|
||||
/>
|
||||
<span class="space"> </span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span>
|
||||
has invited you to join
|
||||
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
|
||||
{{ organization.name }} </nuxt-link
|
||||
>.
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="type === 'status_change' && project">
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
|
||||
has been
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
<template v-else>
|
||||
updated from
|
||||
<ProjectStatusBadge :status="notification.body.old_status" />
|
||||
to
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
by the moderators.
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator_message' && thread && project && !report">
|
||||
Your project,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{
|
||||
project.title
|
||||
}}</nuxt-link>
|
||||
, has received
|
||||
<template v-if="notification.grouped_notifs"> messages</template>
|
||||
<template v-else>a message</template>
|
||||
from the moderators.
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator_message' && thread && report">
|
||||
A moderator replied to your report of
|
||||
<template v-if="version">
|
||||
version
|
||||
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
|
||||
{{ version.name }}
|
||||
</nuxt-link>
|
||||
of project
|
||||
</template>
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
|
||||
{{ user.username }}
|
||||
</nuxt-link>
|
||||
.
|
||||
</template>
|
||||
<nuxt-link v-else :to="notification.link" class="title-link">
|
||||
<span v-html="renderString(notification.title)" />
|
||||
</nuxt-link>
|
||||
<button
|
||||
v-for="(action, actionIndex) in notification.actions"
|
||||
:key="actionIndex"
|
||||
class="iconified-button"
|
||||
:class="{ 'raised-button': raised }"
|
||||
@click="performAction(notification, actionIndex)"
|
||||
>
|
||||
<CheckIcon v-if="action.title === 'Accept'" />
|
||||
<XIcon v-else-if="action.title === 'Deny'" />
|
||||
{{ action.title }}
|
||||
</button>
|
||||
<button
|
||||
v-if="notification.actions.length === 0 && !notification.read"
|
||||
class="iconified-button"
|
||||
:class="{ 'raised-button': raised }"
|
||||
@click="performAction(notification, null)"
|
||||
>
|
||||
<CheckIcon />
|
||||
Mark as read
|
||||
</button>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasBody" class="notification__body">
|
||||
<ThreadSummary
|
||||
v-if="type === 'moderator_message' && thread"
|
||||
:thread="thread"
|
||||
:link="threadLink"
|
||||
:raised="raised"
|
||||
:messages="getMessages()"
|
||||
class="thread-summary"
|
||||
:auth="auth"
|
||||
/>
|
||||
<div v-else-if="type === 'project_update'" class="version-list">
|
||||
<div
|
||||
v-for="notif in (notification.grouped_notifs
|
||||
? [notification, ...notification.grouped_notifs]
|
||||
: [notification]
|
||||
).filter((x) => x.extra_data.version)"
|
||||
:key="notif.id"
|
||||
class="version-link"
|
||||
>
|
||||
<VersionIcon />
|
||||
<nuxt-link
|
||||
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
|
||||
class="text-link"
|
||||
>
|
||||
{{ notif.extra_data.version.name }}
|
||||
</nuxt-link>
|
||||
<span class="version-info">
|
||||
for
|
||||
<Categories
|
||||
:categories="getLoaderCategories(notif.extra_data.version)"
|
||||
:type="notif.extra_data.project.project_type"
|
||||
class="categories"
|
||||
/>
|
||||
{{ $formatVersion(notif.extra_data.version.game_versions) }}
|
||||
<span
|
||||
v-tooltip="formatDateTime(notif.extra_data.version.date_published)"
|
||||
class="date"
|
||||
>
|
||||
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ notification.text }}
|
||||
</template>
|
||||
</div>
|
||||
<span class="notification__date">
|
||||
<span v-if="notification.read" class="read-badge inline-flex">
|
||||
<CheckCircleIcon /> Read
|
||||
</span>
|
||||
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex">
|
||||
<CalendarIcon class="mr-1" /> Received
|
||||
{{ formatRelativeTime(notification.created) }}
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="compact" class="notification__actions">
|
||||
<template v-if="type === 'team_invite' || type === 'organization_invite'">
|
||||
<ButtonStyled circular color="brand" type="transparent">
|
||||
<button
|
||||
v-tooltip="`Accept`"
|
||||
@click="
|
||||
() => {
|
||||
acceptTeamInvite(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular color="red" type="transparent">
|
||||
<button
|
||||
v-tooltip="`Decline`"
|
||||
@click="
|
||||
() => {
|
||||
removeSelfFromTeam(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else-if="!notification.read" circular type="transparent">
|
||||
<button v-tooltip="`Mark as read`" @click="read()">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="notification__actions">
|
||||
<div v-if="type !== null" class="input-group">
|
||||
<template
|
||||
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
|
||||
>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
acceptTeamInvite(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<CheckIcon />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
removeSelfFromTeam(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else-if="!notification.read">
|
||||
<button @click="read()">
|
||||
<CheckIcon />
|
||||
Mark as read
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
<div v-else class="input-group">
|
||||
<ButtonStyled v-if="notification.link && notification.link !== '#'">
|
||||
<nuxt-link :to="notification.link" target="_blank">
|
||||
<ExternalIcon />
|
||||
Open link
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-for="(action, actionIndex) in notification.actions" :key="actionIndex">
|
||||
<button @click="performAction(notification, actionIndex)">
|
||||
<CheckIcon v-if="action.title === 'Accept'" />
|
||||
<XIcon v-else-if="action.title === 'Deny'" />
|
||||
{{ action.title }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="notification.actions.length === 0 && !notification.read">
|
||||
<button @click="performAction(notification, null)">
|
||||
<CheckIcon />
|
||||
Mark as read
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -333,11 +390,14 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
Categories,
|
||||
CopyCode,
|
||||
DoubleIcon,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
ProjectStatusBadge,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { getUserLink, renderString } from '@modrinth/utils'
|
||||
@@ -346,11 +406,18 @@ import { markAsRead } from '~/helpers/platform-notifications'
|
||||
import { getProjectLink, getVersionLink } from '~/helpers/projects'
|
||||
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams'
|
||||
|
||||
import ModrinthServersIcon from '../brand/ModrinthServersIcon.vue'
|
||||
import ThreadSummary from './thread/ThreadSummary.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const emit = defineEmits(['update:notifications'])
|
||||
const router = useRouter()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
notification: {
|
||||
@@ -410,7 +477,7 @@ async function read() {
|
||||
? props.notification.grouped_notifs.map((notif) => notif.id)
|
||||
: []),
|
||||
]
|
||||
const updateNotifs = await markAsRead(ids)
|
||||
const updateNotifs = await markAsRead(client, ids)
|
||||
const newNotifs = updateNotifs(props.notifications)
|
||||
emit('update:notifications', newNotifs)
|
||||
} catch (err) {
|
||||
@@ -428,9 +495,29 @@ async function performAction(notification, actionIndex) {
|
||||
await read()
|
||||
|
||||
if (actionIndex !== null) {
|
||||
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
|
||||
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
|
||||
})
|
||||
const action = notification.actions[actionIndex]
|
||||
|
||||
if (type.value === 'server_invite') {
|
||||
const actionName = action.title.toLowerCase()
|
||||
const inviteAction = actionName === 'accept' ? 'accept' : 'decline'
|
||||
const serverId = notification.body.server_id
|
||||
|
||||
await client.request(`/servers/${serverId}/invites/${inviteAction}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (inviteAction === 'accept') {
|
||||
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
|
||||
}
|
||||
} else {
|
||||
const [method, route] = action.action_route
|
||||
|
||||
await useBaseFetch(route, {
|
||||
method: method.toUpperCase(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
@@ -442,6 +529,20 @@ async function performAction(notification, actionIndex) {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
function performActionByTitle(notification, title) {
|
||||
const actionIndex = notification.actions.findIndex((action) => action.title === title)
|
||||
if (actionIndex === -1) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: `Missing ${title.toLowerCase()} action for notification.`,
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
return performAction(notification, actionIndex)
|
||||
}
|
||||
|
||||
function getMessages() {
|
||||
const messages = []
|
||||
if (props.notification.body.message_id) {
|
||||
@@ -458,9 +559,11 @@ function getMessages() {
|
||||
}
|
||||
|
||||
function getLoaderCategories(ver) {
|
||||
return tags.value.loaders.filter((loader) => {
|
||||
return ver?.loaders?.includes(loader.name)
|
||||
})
|
||||
return tags.value.loaders
|
||||
.filter((loader) => {
|
||||
return ver?.loaders?.includes(loader.name)
|
||||
})
|
||||
.map((loader) => loader.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -593,10 +696,6 @@ function getLoaderCategories(ver) {
|
||||
gap: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.notification__actions .iconified-button.square-button svg {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.unknown-type {
|
||||
color: var(--color-red);
|
||||
}
|
||||
@@ -617,4 +716,8 @@ function getLoaderCategories(ver) {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
}
|
||||
|
||||
.title-link {
|
||||
@apply underline hover:brightness-[--hover-brightness];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
class="card-shadow relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
|
||||
@@ -67,50 +67,55 @@
|
||||
</div>
|
||||
|
||||
<div class="table-cell">
|
||||
<nuxt-link
|
||||
class="btn icon-only"
|
||||
:to="`/project/${project.slug ? project.slug : project.id}/settings`"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</nuxt-link>
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link :to="`/project/${project.slug ? project.slug : project.id}/settings`">
|
||||
<SettingsIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="push-right input-group">
|
||||
<Button @click="$refs.modalOpen?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button :disabled="!selectedProjects?.length" color="primary" @click="onSubmitHandler()">
|
||||
<TransferIcon />
|
||||
<span>
|
||||
Transfer
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="$refs.modalOpen?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!selectedProjects?.length" @click="onSubmitHandler()">
|
||||
<TransferIcon />
|
||||
<span>
|
||||
{{
|
||||
selectedProjects.length === props.projects.length
|
||||
? 'All'
|
||||
: selectedProjects.length
|
||||
}}
|
||||
Transfer
|
||||
<span>
|
||||
{{
|
||||
selectedProjects.length === props.projects.length
|
||||
? 'All'
|
||||
: selectedProjects.length
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
{{ ' ' }}
|
||||
{{ selectedProjects.length === 1 ? 'project' : 'projects' }}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{{ ' ' }}
|
||||
{{ selectedProjects.length === 1 ? 'project' : 'projects' }}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Button @click="$refs.modalOpen?.show()">
|
||||
<TransferIcon />
|
||||
<span>Transfer projects</span>
|
||||
</Button>
|
||||
<ButtonStyled>
|
||||
<button @click="$refs.modalOpen?.show()">
|
||||
<TransferIcon />
|
||||
<span>Transfer projects</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from '@modrinth/assets'
|
||||
import { Avatar, Button, Checkbox, CopyCode, Modal } from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, Checkbox, CopyCode, Modal } from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
<template>
|
||||
<article class="project-card base-card padding-bg" :aria-label="name" role="listitem">
|
||||
<nuxt-link
|
||||
:title="name"
|
||||
class="icon"
|
||||
tabindex="-1"
|
||||
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
|
||||
>
|
||||
<Avatar :src="iconUrl" :alt="name" size="md" no-shadow loading="lazy" />
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
class="gallery"
|
||||
:class="{ 'no-image': !featuredImage }"
|
||||
tabindex="-1"
|
||||
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
|
||||
:style="color ? `background-color: ${toColor};` : ''"
|
||||
>
|
||||
<img v-if="featuredImage" :src="featuredImage" alt="gallery image" loading="lazy" />
|
||||
</nuxt-link>
|
||||
<div class="title">
|
||||
<nuxt-link :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`">
|
||||
<h2 class="name !text-2xl">
|
||||
{{ name }}
|
||||
</h2>
|
||||
</nuxt-link>
|
||||
<p v-if="author" class="author">
|
||||
by
|
||||
<nuxt-link class="title-link" :to="'/user/' + author">
|
||||
{{ author }}
|
||||
</nuxt-link>
|
||||
</p>
|
||||
<ProjectStatusBadge v-if="status && status !== 'approved'" :status="status" class="status" />
|
||||
</div>
|
||||
<p class="description">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Categories
|
||||
:categories="
|
||||
categories.filter((x) => !hideLoaders || !tags.loaders.find((y) => y.name === x))
|
||||
"
|
||||
:type="type"
|
||||
class="tags"
|
||||
>
|
||||
<EnvironmentIndicator
|
||||
v-if="clientSide && serverSide"
|
||||
:type-only="moderation"
|
||||
:client-side="clientSide"
|
||||
:server-side="serverSide"
|
||||
:type="projectTypeDisplay"
|
||||
:search="search"
|
||||
:categories="categories"
|
||||
/>
|
||||
</Categories>
|
||||
<div class="stats">
|
||||
<div v-if="downloads" class="stat">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<p>
|
||||
<strong>{{ $formatNumber(downloads) }}</strong
|
||||
><span class="stat-label"> download<span v-if="downloads !== '1'">s</span></span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="follows" class="stat">
|
||||
<HeartIcon aria-hidden="true" />
|
||||
<p>
|
||||
<strong>{{ $formatNumber(follows) }}</strong
|
||||
><span class="stat-label"> follower<span v-if="follows !== '1'">s</span></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="showUpdatedDate"
|
||||
v-tooltip="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="stat date"
|
||||
>
|
||||
<UpdatedIcon aria-hidden="true" />
|
||||
<span class="date-label">Updated </span>{{ formatRelativeTime(updatedAt) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showCreatedDate"
|
||||
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="stat date"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<span class="date-label">Published </span>{{ formatRelativeTime(createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CalendarIcon, DownloadIcon, HeartIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { Avatar, ProjectStatusBadge, useRelativeTime } from '@modrinth/ui'
|
||||
|
||||
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator.vue'
|
||||
import Categories from '~/components/ui/search/Categories.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ProjectStatusBadge,
|
||||
EnvironmentIndicator,
|
||||
Avatar,
|
||||
Categories,
|
||||
CalendarIcon,
|
||||
UpdatedIcon,
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: 'modrinth-0',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'mod',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'Project Name',
|
||||
},
|
||||
author: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'A _type description',
|
||||
},
|
||||
iconUrl: {
|
||||
type: String,
|
||||
default: '#',
|
||||
required: false,
|
||||
},
|
||||
downloads: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
follows: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: String,
|
||||
default: '0000-00-00',
|
||||
},
|
||||
updatedAt: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hasModMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
serverSide: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
clientSide: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
moderation: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
search: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
featuredImage: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
showUpdatedDate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
showCreatedDate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
hideLoaders: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const tags = useGeneratedState()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
return { tags, formatRelativeTime }
|
||||
},
|
||||
computed: {
|
||||
projectTypeDisplay() {
|
||||
return this.$getProjectTypeForDisplay(this.type, this.categories)
|
||||
},
|
||||
toColor() {
|
||||
let color = this.color
|
||||
|
||||
color >>>= 0
|
||||
const b = color & 0xff
|
||||
const g = (color & 0xff00) >>> 8
|
||||
const r = (color & 0xff0000) >>> 16
|
||||
return 'rgba(' + [r, g, b, 1].join(',') + ')'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-card {
|
||||
display: inline-grid;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.display-mode--list .project-card {
|
||||
grid-template:
|
||||
'icon title stats'
|
||||
'icon description stats'
|
||||
'icon tags stats';
|
||||
grid-template-columns: min-content 1fr auto;
|
||||
grid-template-rows: min-content 1fr min-content;
|
||||
column-gap: var(--spacing-card-md);
|
||||
row-gap: var(--spacing-card-sm);
|
||||
width: 100%;
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
grid-template:
|
||||
'icon title'
|
||||
'icon description'
|
||||
'icon tags'
|
||||
'stats stats';
|
||||
grid-template-columns: min-content auto;
|
||||
grid-template-rows: min-content 1fr min-content min-content;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 550px) {
|
||||
grid-template:
|
||||
'icon title'
|
||||
'icon description'
|
||||
'tags tags'
|
||||
'stats stats';
|
||||
grid-template-columns: min-content auto;
|
||||
grid-template-rows: min-content 1fr min-content min-content;
|
||||
}
|
||||
}
|
||||
|
||||
.display-mode--gallery .project-card,
|
||||
.display-mode--grid .project-card {
|
||||
padding: 0 0 var(--spacing-card-bg) 0;
|
||||
grid-template: 'gallery gallery' 'icon title' 'description description' 'tags tags' 'stats stats';
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-template-rows: min-content min-content 1fr min-content min-content;
|
||||
row-gap: var(--spacing-card-sm);
|
||||
|
||||
.gallery {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
background-color: var(--color-button-bg-active);
|
||||
|
||||
&.no-image {
|
||||
filter: brightness(0.7);
|
||||
}
|
||||
|
||||
img {
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: var(--spacing-card-bg);
|
||||
margin-top: -3rem;
|
||||
z-index: 1;
|
||||
|
||||
img,
|
||||
svg {
|
||||
border-radius: var(--size-rounded-lg);
|
||||
box-shadow:
|
||||
-2px -2px 0 2px var(--color-raised-bg),
|
||||
2px -2px 0 2px var(--color-raised-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: var(--spacing-card-md);
|
||||
margin-right: var(--spacing-card-bg);
|
||||
flex-direction: column;
|
||||
|
||||
.name {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-inline: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-inline: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-inline: var(--spacing-card-bg);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.stat-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-card-sm);
|
||||
align-items: center;
|
||||
|
||||
> :first-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&:first-child > :last-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons:not(:empty) + .date {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.display-mode--grid .project-card {
|
||||
.gallery {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
|
||||
|
||||
img,
|
||||
svg {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
grid-area: icon;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: none;
|
||||
height: 10rem;
|
||||
grid-area: gallery;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: var(--spacing-card-sm);
|
||||
row-gap: 0;
|
||||
word-wrap: anywhere;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: auto;
|
||||
color: var(--color-orange);
|
||||
height: 1.5rem;
|
||||
margin-bottom: -0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-area: stats;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-card-md);
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
gap: var(--spacing-card-xs);
|
||||
--stat-strong-size: 1.25rem;
|
||||
|
||||
strong {
|
||||
font-size: var(--stat-strong-size);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: var(--stat-strong-size);
|
||||
width: var(--stat-strong-size);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
flex-direction: row;
|
||||
column-gap: var(--spacing-card-md);
|
||||
margin-top: var(--spacing-card-xs);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
margin-top: 0;
|
||||
|
||||
.stat-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.environment {
|
||||
color: var(--color-text) !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-area: description;
|
||||
margin-block: 0;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tags {
|
||||
grid-area: tags;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@media screen and (max-width: 550px) {
|
||||
margin-top: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
align-items: flex-end;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.small-mode {
|
||||
@media screen and (min-width: 750px) {
|
||||
grid-template:
|
||||
'icon title'
|
||||
'icon description'
|
||||
'icon tags'
|
||||
'stats stats' !important;
|
||||
grid-template-columns: min-content auto !important;
|
||||
grid-template-rows: min-content 1fr min-content min-content !important;
|
||||
|
||||
.tags {
|
||||
margin-top: var(--spacing-card-xs) !important;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: row;
|
||||
column-gap: var(--spacing-card-md) !important;
|
||||
margin-top: var(--spacing-card-xs) !important;
|
||||
|
||||
.stat-label {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,13 +9,13 @@
|
||||
<ButtonStyled color="brand">
|
||||
<button class="brand-button" @click="acceptInvite()">
|
||||
<CheckIcon />
|
||||
{{ getFormattedMessage(messages.accept) }}
|
||||
{{ getFormattedMessage(commonMessages.acceptButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="declineInvite">
|
||||
<XIcon />
|
||||
{{ getFormattedMessage(messages.decline) }}
|
||||
{{ getFormattedMessage(commonMessages.declineButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -26,6 +26,7 @@
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
type MessageDescriptor,
|
||||
@@ -81,14 +82,6 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"You've been invited to join this project. Please accept or decline the invitation.",
|
||||
},
|
||||
accept: {
|
||||
id: 'project-member-header.accept',
|
||||
defaultMessage: 'Accept',
|
||||
},
|
||||
decline: {
|
||||
id: 'project-member-header.decline',
|
||||
defaultMessage: 'Decline',
|
||||
},
|
||||
successJoin: {
|
||||
id: 'project-member-header.success-join',
|
||||
defaultMessage: 'You have joined the project team',
|
||||
@@ -105,14 +98,6 @@ const messages = defineMessages({
|
||||
id: 'project-member-header.error-decline',
|
||||
defaultMessage: 'Failed to decline team invitation',
|
||||
},
|
||||
success: {
|
||||
id: 'project-member-header.success',
|
||||
defaultMessage: 'Success',
|
||||
},
|
||||
error: {
|
||||
id: 'project-member-header.error',
|
||||
defaultMessage: 'Error',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -171,13 +156,13 @@ async function acceptInvite(): Promise<void> {
|
||||
await acceptTeamInvite(props.project.team)
|
||||
await handleUpdateMembers()
|
||||
addNotification({
|
||||
title: formatMessage(messages.success),
|
||||
title: formatMessage(commonMessages.successLabel),
|
||||
text: formatMessage(messages.successJoin),
|
||||
type: 'success',
|
||||
})
|
||||
} catch {
|
||||
addNotification({
|
||||
title: formatMessage(messages.error),
|
||||
title: formatMessage(commonMessages.errorLabel),
|
||||
text: formatMessage(messages.errorJoin),
|
||||
type: 'error',
|
||||
})
|
||||
@@ -192,13 +177,13 @@ async function declineInvite(): Promise<void> {
|
||||
await removeTeamMember(props.project.team, props.auth.user.id)
|
||||
await handleUpdateMembers()
|
||||
addNotification({
|
||||
title: formatMessage(messages.success),
|
||||
title: formatMessage(commonMessages.successLabel),
|
||||
text: formatMessage(messages.successDecline),
|
||||
type: 'success',
|
||||
})
|
||||
} catch {
|
||||
addNotification({
|
||||
title: formatMessage(messages.error),
|
||||
title: formatMessage(commonMessages.errorLabel),
|
||||
text: formatMessage(messages.errorDecline),
|
||||
type: 'error',
|
||||
})
|
||||
|
||||
+27
-33
@@ -1,19 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
ServerNotice,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
} from '@modrinth/ui'
|
||||
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
type ServerNoticeType = Archon.Notices.v0.ListedNotice
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
@@ -31,28 +34,23 @@ const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === 'no
|
||||
const inputField = ref('')
|
||||
|
||||
async function refresh() {
|
||||
await useServersFetch('notices').then((res) => {
|
||||
const notices = res as ServerNoticeType[]
|
||||
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
|
||||
})
|
||||
const notices = await client.archon.notices_v0.list()
|
||||
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
|
||||
}
|
||||
|
||||
async function assign(server: boolean = true) {
|
||||
const input = inputField.value.trim()
|
||||
|
||||
if (input !== '' && notice.value) {
|
||||
await useServersFetch(
|
||||
`notices/${notice.value.id}/assign?${server ? 'server' : 'node'}=${input}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
},
|
||||
).catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error assigning notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
await client.archon.notices_v0
|
||||
.assign(notice.value.id, server ? { server: input } : { node: input })
|
||||
.catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error assigning notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Error assigning notice',
|
||||
@@ -83,18 +81,15 @@ async function unassignDetect() {
|
||||
|
||||
async function unassign(id: string, server: boolean = true) {
|
||||
if (notice.value) {
|
||||
await useServersFetch(
|
||||
`notices/${notice.value.id}/unassign?${server ? 'server' : 'node'}=${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
},
|
||||
).catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error unassigning notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
await client.archon.notices_v0
|
||||
.unassign(notice.value.id, server ? { server: id } : { node: id })
|
||||
.catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error unassigning notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
@@ -124,7 +119,7 @@ defineExpose({ show, hide })
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
:title="notice.title ?? undefined"
|
||||
preview
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -180,11 +175,10 @@ defineExpose({ show, hide })
|
||||
<span v-else class="mb-2"> No nodes assigned yet </span>
|
||||
</div>
|
||||
<div class="flex w-[45rem] items-center gap-2">
|
||||
<input
|
||||
<StyledInput
|
||||
id="server-assign-field"
|
||||
v-model="inputField"
|
||||
class="w-full"
|
||||
type="text"
|
||||
wrapper-class="w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ButtonStyled color="green" color-fill="text">
|
||||
@@ -20,12 +20,12 @@
|
||||
<label for="days" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Days to credit </span>
|
||||
</label>
|
||||
<input
|
||||
<StyledInput
|
||||
id="days"
|
||||
v-model.number="days"
|
||||
class="w-32"
|
||||
v-model="days"
|
||||
wrapper-class="w-32"
|
||||
type="number"
|
||||
min="1"
|
||||
:min="1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -36,11 +36,10 @@
|
||||
<span class="text-lg font-semibold text-contrast"> Node hostnames </span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
<StyledInput
|
||||
id="node-input"
|
||||
v-model="nodeInput"
|
||||
class="w-32"
|
||||
type="text"
|
||||
wrapper-class="w-32"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
@@ -90,17 +89,16 @@
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="text-muted flex flex-col gap-2 rounded-lg border border-divider bg-button-bg p-4"
|
||||
class="text-muted flex flex-col gap-2 rounded-lg border border-surface-5 bg-button-bg p-4"
|
||||
>
|
||||
<span>Hi {user.name},</span>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="message-batch"
|
||||
v-model="message"
|
||||
rows="3"
|
||||
class="w-full overflow-hidden !bg-surface-3"
|
||||
/>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="message-batch"
|
||||
v-model="message"
|
||||
multiline
|
||||
:rows="3"
|
||||
input-class="!bg-surface-3"
|
||||
/>
|
||||
<span>
|
||||
To make up for it, we've added {{ days }} day{{ pluralize(days) }} to your Modrinth
|
||||
Servers subscription.
|
||||
@@ -135,8 +133,10 @@ import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
@@ -144,9 +144,9 @@ import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useBaseFetch } from '#imports'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
@@ -206,12 +206,12 @@ const applyDisabled = computed(() => {
|
||||
async function ensureOverview() {
|
||||
if (regions.value.length || nodeHostnames.value.length) return
|
||||
try {
|
||||
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
|
||||
regions.value = (data.regions || []).map((r: any) => ({
|
||||
const data = await client.archon.nodes_internal.overview()
|
||||
regions.value = data.regions.map((r) => ({
|
||||
value: r.key,
|
||||
label: `${r.display_name} (${r.key})`,
|
||||
}))
|
||||
nodeHostnames.value = data.node_hostnames || []
|
||||
nodeHostnames.value = data.node_hostnames
|
||||
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0].value
|
||||
} catch (err) {
|
||||
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
|
||||
|
||||
@@ -25,15 +25,14 @@
|
||||
</span>
|
||||
<span>Server IDs (one per line or comma-separated.)</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="server-ids"
|
||||
v-model="serverIdsInput"
|
||||
rows="4"
|
||||
class="w-full bg-surface-3"
|
||||
placeholder="123e4569-e89b-12d3-a456-426614174005 123e9569-e89b-12d3-a456-413678919876"
|
||||
/>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="server-ids"
|
||||
v-model="serverIdsInput"
|
||||
multiline
|
||||
:rows="4"
|
||||
input-class="bg-surface-3"
|
||||
placeholder="123e4569-e89b-12d3-a456-426614174005 123e9569-e89b-12d3-a456-413678919876"
|
||||
/>
|
||||
<span v-if="parsedServerIds.length" class="text-sm text-secondary">
|
||||
{{ parsedServerIds.length }} server{{ parsedServerIds.length === 1 ? '' : 's' }} selected
|
||||
</span>
|
||||
@@ -46,20 +45,19 @@
|
||||
Node hostnames
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>Add nodes to transfer.</span>
|
||||
<span>Add nodes to transfer (comma or space-separated).</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
<StyledInput
|
||||
id="node-input"
|
||||
v-model="nodeInput"
|
||||
class="w-40"
|
||||
type="text"
|
||||
wrapper-class="w-64"
|
||||
autocomplete="off"
|
||||
placeholder="us-vin200"
|
||||
@keydown.enter.prevent="addNode"
|
||||
placeholder="us-vin200, us-vin201"
|
||||
@keydown.enter.prevent="addNodes"
|
||||
/>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
<button class="shrink-0" @click="addNode">
|
||||
<button class="shrink-0" @click="addNodes">
|
||||
<PlusIcon />
|
||||
Add
|
||||
</button>
|
||||
@@ -88,11 +86,10 @@
|
||||
<span class="text-lg font-semibold text-contrast">Tag transferred nodes</span>
|
||||
<span>Optional tag to add to the transferred nodes.</span>
|
||||
</label>
|
||||
<input
|
||||
<StyledInput
|
||||
id="tag-nodes"
|
||||
v-model="tagNodes"
|
||||
class="max-w-[12rem]"
|
||||
type="text"
|
||||
wrapper-class="max-w-[12rem]"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -117,11 +114,10 @@
|
||||
<span>Optional preferred node tags for node selection.</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
<StyledInput
|
||||
id="tag-input"
|
||||
v-model="tagInput"
|
||||
class="w-40"
|
||||
type="text"
|
||||
wrapper-class="w-40"
|
||||
autocomplete="off"
|
||||
placeholder="ovh-gen4"
|
||||
@keydown.enter.prevent="addTag"
|
||||
@@ -151,11 +147,11 @@
|
||||
:format-label="(item) => scheduleOptionLabels[item]"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<input
|
||||
<StyledInput
|
||||
v-if="scheduleOption === 'later'"
|
||||
v-model="scheduledDate"
|
||||
type="datetime-local"
|
||||
class="mt-2 max-w-[16rem]"
|
||||
wrapper-class="mt-2 max-w-[16rem]"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -168,15 +164,14 @@
|
||||
</span>
|
||||
<span>Provide a reason for this transfer batch.</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="reason"
|
||||
v-model="reason"
|
||||
rows="2"
|
||||
class="w-full bg-surface-3"
|
||||
placeholder="Node maintenance scheduled"
|
||||
/>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="reason"
|
||||
v-model="reason"
|
||||
multiline
|
||||
:rows="2"
|
||||
input-class="bg-surface-3"
|
||||
placeholder="Node maintenance scheduled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
@@ -203,21 +198,22 @@ import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
@@ -282,18 +278,37 @@ function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
const v = nodeInput.value.trim()
|
||||
if (!v) return
|
||||
if (!nodeHostnames.value.includes(v)) {
|
||||
function addNodes() {
|
||||
const input = nodeInput.value.trim()
|
||||
if (!input) return
|
||||
|
||||
const nodes = input
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
const unknownNodes: string[] = []
|
||||
const addedNodes: string[] = []
|
||||
|
||||
for (const v of nodes) {
|
||||
if (!nodeHostnames.value.includes(v)) {
|
||||
unknownNodes.push(v)
|
||||
continue
|
||||
}
|
||||
if (!selectedNodes.value.includes(v)) {
|
||||
selectedNodes.value.push(v)
|
||||
addedNodes.push(v)
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownNodes.length > 0) {
|
||||
addNotification({
|
||||
title: 'Unknown node',
|
||||
text: "This hostname doesn't exist",
|
||||
title: `Unknown node${unknownNodes.length > 1 ? 's' : ''}`,
|
||||
text: unknownNodes.join(', '),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!selectedNodes.value.includes(v)) selectedNodes.value.push(v)
|
||||
|
||||
nodeInput.value = ''
|
||||
}
|
||||
|
||||
@@ -326,12 +341,12 @@ const submitDisabled = computed(() => {
|
||||
async function ensureOverview() {
|
||||
if (regions.value.length || nodeHostnames.value.length) return
|
||||
try {
|
||||
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
|
||||
regions.value = (data.regions || []).map((r: any) => ({
|
||||
const data = await client.archon.nodes_internal.overview()
|
||||
regions.value = data.regions.map((r) => ({
|
||||
value: r.key,
|
||||
label: `${r.display_name} (${r.key})`,
|
||||
}))
|
||||
nodeHostnames.value = data.node_hostnames || []
|
||||
nodeHostnames.value = data.node_hostnames
|
||||
if (!selectedRegion.value && regions.value.length) {
|
||||
selectedRegion.value = regions.value[0].value
|
||||
}
|
||||
@@ -349,30 +364,22 @@ async function submit() {
|
||||
scheduleOption.value === 'now' ? undefined : dayjs(scheduledDate.value).toISOString()
|
||||
|
||||
if (mode.value === 'servers') {
|
||||
await useServersFetch('/transfers/schedule/servers', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
server_ids: parsedServerIds.value,
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
},
|
||||
await client.archon.transfers_internal.scheduleServers({
|
||||
server_ids: parsedServerIds.value,
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
})
|
||||
} else {
|
||||
await useServersFetch('/transfers/schedule/nodes', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
node_hostnames: selectedNodes.value.slice(),
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
cordon_nodes: cordonNodes.value,
|
||||
tag_nodes: tagNodes.value.trim() || undefined,
|
||||
},
|
||||
await client.archon.transfers_internal.scheduleNodes({
|
||||
node_hostnames: selectedNodes.value.slice(),
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
cordon_nodes: cordonNodes.value,
|
||||
tag_nodes: tagNodes.value.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
import { XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const tempIgnored = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
@@ -13,16 +17,34 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}",
|
||||
},
|
||||
ignoreErrors: {
|
||||
id: 'layout.banner.build-fail.ignore',
|
||||
defaultMessage: 'Ignore',
|
||||
},
|
||||
alwaysIgnore: {
|
||||
id: 'layout.banner.build-fail.always-ignore',
|
||||
defaultMessage: 'Always ignore',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
errors: any[] | undefined
|
||||
apiUrl: string
|
||||
}>()
|
||||
|
||||
function alwaysIgnoreBanner() {
|
||||
flags.value.alwaysIgnoreErrorBanner = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="errors?.length" variant="error">
|
||||
<PagewideBanner
|
||||
v-if="
|
||||
flags.showAllBanners || (errors?.length && !tempIgnored && !flags.alwaysIgnoreErrorBanner)
|
||||
"
|
||||
variant="error"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
@@ -34,5 +56,19 @@ defineProps<{
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<ButtonStyled color="red" type="transparent" hover-color-fill="background">
|
||||
<button @click="alwaysIgnoreBanner">
|
||||
<XCircleIcon />
|
||||
{{ formatMessage(messages.alwaysIgnore) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="tempIgnored = true">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.ignoreErrors) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
PagewideBanner,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
@@ -21,7 +21,7 @@ const messages = defineMessages({
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.preview.description',
|
||||
defaultMessage: `If you meant to access the official Modrinth website, visit <link>https://modrinth.com</link>. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}.`,
|
||||
defaultMessage: `If you meant to access the official Modrinth website, visit {url}. This preview deploy is used by Modrinth staff for testing purposes. It was built using {ref}.`,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -29,39 +29,33 @@ function hidePreviewBanner() {
|
||||
flags.value.hidePreviewBanner = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
|
||||
const url = computed(() => `https://modrinth.com${route.fullPath}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="!flags.hidePreviewBanner" variant="info">
|
||||
<PagewideBanner v-if="!flags.hidePreviewBanner || flags.showAllBanners" variant="info">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>
|
||||
<IntlFormatted
|
||||
:message-id="messages.description"
|
||||
:values="{
|
||||
owner: config.public.owner,
|
||||
branch: config.public.branch,
|
||||
}"
|
||||
>
|
||||
<template #link="{ children }">
|
||||
<a href="https://modrinth.com" target="_blank" rel="noopener" class="text-link">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #url>
|
||||
<a :href="url" target="_blank" rel="noopener" class="text-link">
|
||||
{{ url }}
|
||||
</a>
|
||||
</template>
|
||||
<template #branch-link="{ children }">
|
||||
<template #ref>
|
||||
<a
|
||||
:href="`https://github.com/${config.public.owner}/code/tree/${config.public.branch}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="hover:underline"
|
||||
>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
{{ config.public.owner }} / {{ config.public.branch }}
|
||||
</a>
|
||||
</template>
|
||||
<template #commit>
|
||||
<span v-if="config.public.hash === 'unknown'">unknown</span>
|
||||
@ <span v-if="config.public.hash === 'unknown'">unknown</span>
|
||||
<a
|
||||
v-else
|
||||
:href="`https://github.com/${config.public.owner}/code/commit/${config.public.hash}`"
|
||||
@@ -75,7 +69,7 @@ function hidePreviewBanner() {
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<template #actions_top_right>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hidePreviewBanner">
|
||||
<XIcon aria-hidden="true" />
|
||||
|
||||
@@ -47,7 +47,7 @@ function hideRussiaCensorshipBanner() {
|
||||
<span class="text-xs font-medium">(Перевод на русский)</span>
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<ButtonStyled type="transparent" hover-color-fill="background">
|
||||
<nuxt-link to="/news/article/standing-by-our-values">
|
||||
<BookTextIcon /> Read our full statement
|
||||
<span class="text-xs font-medium">(English)</span>
|
||||
@@ -55,7 +55,7 @@ function hideRussiaCensorshipBanner() {
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<template #actions_top_right>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.closeButton)"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const cosmetics = useCosmetics()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
@@ -29,14 +30,14 @@ function hideStagingBanner() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="!cosmetics.hideStagingBanner" variant="warning">
|
||||
<PagewideBanner v-if="flags.showAllBanners || !cosmetics.hideStagingBanner" variant="warning">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
{{ formatMessage(messages.description) }}
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<template #actions_top_right>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hideStagingBanner">
|
||||
<XIcon aria-hidden="true" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { SettingsIcon } from '@modrinth/assets'
|
||||
import { defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
import { ButtonStyled, defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -29,11 +29,13 @@ const messages = defineMessages({
|
||||
<template #description>
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<nuxt-link class="btn" to="/settings/billing">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.action) }}
|
||||
</nuxt-link>
|
||||
<template #actions_right>
|
||||
<ButtonStyled color="red">
|
||||
<nuxt-link to="/settings/billing">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.action) }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { FileTextIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
PagewideBanner,
|
||||
useFormatMoney,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getTaxThreshold } from '@/providers/creator-withdraw.ts'
|
||||
import CreatorTaxFormModal from '~/components/ui/dashboard/CreatorTaxFormModal.vue'
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatMoney = useFormatMoney()
|
||||
|
||||
const generatedState = useGeneratedState()
|
||||
const taxThreshold = computed(() => getTaxThreshold(generatedState.value?.taxComplianceThresholds))
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
@@ -16,7 +29,7 @@ const messages = defineMessages({
|
||||
description: {
|
||||
id: 'layout.banner.tax.description',
|
||||
defaultMessage:
|
||||
"You've already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted.",
|
||||
"You've already withdrawn over {threshold} from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted.",
|
||||
},
|
||||
action: {
|
||||
id: 'layout.banner.tax.action',
|
||||
@@ -38,9 +51,11 @@ function openTaxForm(e: MouseEvent) {
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
<span>{{
|
||||
formatMessage(messages.description, { threshold: formatMoney(taxThreshold) })
|
||||
}}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template #actions_right>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="openTaxForm"><FileTextIcon /> {{ formatMessage(messages.action) }}</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -29,7 +29,7 @@ const messages = defineMessages({
|
||||
<template #description>
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template #actions_right>
|
||||
<div class="flex w-fit flex-row">
|
||||
<ButtonStyled color="red">
|
||||
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { SettingsIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
PagewideBanner,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { FetchError } from 'ofetch'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
@@ -90,14 +96,16 @@ async function handleResendEmailVerification() {
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button v-if="hasEmail" class="btn" @click="handleResendEmailVerification">
|
||||
{{ formatMessage(verifyEmailBannerMessages.action) }}
|
||||
</button>
|
||||
<nuxt-link v-else class="btn" to="/settings/account">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(addEmailBannerMessages.action) }}
|
||||
</nuxt-link>
|
||||
<template #actions_right>
|
||||
<ButtonStyled color="orange">
|
||||
<button v-if="hasEmail" @click="handleResendEmailVerification">
|
||||
{{ formatMessage(verifyEmailBannerMessages.action) }}
|
||||
</button>
|
||||
<nuxt-link v-else to="/settings/account">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(addEmailBannerMessages.action) }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { PagewideBanner } from '@modrinth/ui'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const route = useRoute()
|
||||
|
||||
const url = computed(() => `https://modrinth.com${route.fullPath}`)
|
||||
|
||||
const bannerRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
function onProdLinkClick(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
const el = bannerRoot.value
|
||||
if (el) {
|
||||
const { height } = el.getBoundingClientRect()
|
||||
window.scrollBy({ top: Math.ceil(height), behavior: 'auto' })
|
||||
}
|
||||
window.open(url.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="flags.showViewProdRouteBanner || flags.showAllBanners" ref="bannerRoot">
|
||||
<PagewideBanner variant="info" slim>
|
||||
<template #description>
|
||||
<span>
|
||||
View route on production:
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-link"
|
||||
@click="onProdLinkClick"
|
||||
>
|
||||
{{ url }}
|
||||
</a>
|
||||
</span>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,490 +0,0 @@
|
||||
<script setup>
|
||||
import { formatMoney, formatNumber } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
formatLabels: {
|
||||
type: Function,
|
||||
default: (label) => dayjs(label).format('MMM D'),
|
||||
},
|
||||
colors: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hideToolbar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideLegend: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'bar',
|
||||
},
|
||||
hideTotal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMoney: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
legendPosition: {
|
||||
type: String,
|
||||
default: 'right',
|
||||
},
|
||||
xAxisType: {
|
||||
type: String,
|
||||
default: 'datetime',
|
||||
},
|
||||
percentStacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
horizontalBar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableAnimations: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
function formatTooltipValue(value, props) {
|
||||
return props.isMoney ? formatMoney(value, false) : formatNumber(value, false)
|
||||
}
|
||||
|
||||
function generateListEntry(value, index, _, w, props) {
|
||||
const color = w.globals.colors?.[index]
|
||||
|
||||
return `<div class="list-entry">
|
||||
<span class="circle" style="background-color: ${color}"></span>
|
||||
<div class="label">
|
||||
${w.globals.seriesNames[index]}
|
||||
</div>
|
||||
<div class="value">
|
||||
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
|
||||
const label = w.globals.lastXAxis.categories?.[dataPointIndex]
|
||||
|
||||
const formattedLabel = props.formatLabels(label)
|
||||
|
||||
let tooltip = `<div class="bar-tooltip">
|
||||
<div class="seperated-entry title">
|
||||
<div class="label">${formattedLabel}</div>`
|
||||
|
||||
// Logic for total and percent stacked
|
||||
if (!props.hideTotal) {
|
||||
if (props.percentStacked) {
|
||||
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
|
||||
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
|
||||
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
|
||||
props.suffix
|
||||
}</div>`
|
||||
} else {
|
||||
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
|
||||
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
|
||||
props.suffix
|
||||
}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
tooltip += '</div><hr class="card-divider" />'
|
||||
|
||||
// Logic for generating list entries
|
||||
if (props.percentStacked) {
|
||||
tooltip += generateListEntry(
|
||||
series[seriesIndex][dataPointIndex],
|
||||
seriesIndex,
|
||||
seriesIndex,
|
||||
w,
|
||||
props,
|
||||
)
|
||||
} else {
|
||||
const returnTopN = 15
|
||||
|
||||
const listEntries = series
|
||||
.map((value, index) => [
|
||||
value[dataPointIndex],
|
||||
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
|
||||
])
|
||||
.filter((value) => value[0] > 0)
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.slice(0, returnTopN) // Return only the top X entries
|
||||
.map((value) => value[1])
|
||||
.join('')
|
||||
|
||||
tooltip += listEntries
|
||||
}
|
||||
|
||||
tooltip += '</div>'
|
||||
return tooltip
|
||||
}
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
id: props.name,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
selection: {
|
||||
enabled: true,
|
||||
fill: {
|
||||
color: 'var(--color-brand)',
|
||||
},
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
stacked: props.stacked,
|
||||
stackType: props.percentStacked ? '100%' : 'normal',
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
animations: {
|
||||
enabled: props.disableAnimations,
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
type: props.xAxisType,
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
style: {
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
},
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: props.colors,
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
background: {
|
||||
enabled: true,
|
||||
borderRadius: 20,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-button-bg)',
|
||||
tickColor: 'var(--color-button-bg)',
|
||||
},
|
||||
legend: {
|
||||
show: !props.hideLegend,
|
||||
position: props.legendPosition,
|
||||
showForZeroSeries: false,
|
||||
showForSingleSeries: false,
|
||||
showForNullSeries: false,
|
||||
fontSize: 'var(--font-size-nm)',
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
onItemClick: {
|
||||
toggleDataSeries: true,
|
||||
},
|
||||
},
|
||||
markers: {
|
||||
size: 0,
|
||||
strokeColor: 'var(--color-contrast)',
|
||||
strokeWidth: 3,
|
||||
strokeOpacity: 1,
|
||||
fillOpacity: 1,
|
||||
hover: {
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: props.horizontalBar,
|
||||
columnWidth: '80%',
|
||||
endingShape: 'rounded',
|
||||
borderRadius: 5,
|
||||
borderRadiusApplication: 'end',
|
||||
borderRadiusWhenStacked: 'last',
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2,
|
||||
},
|
||||
tooltip: {
|
||||
custom: (d) => generateTooltip(d, props),
|
||||
},
|
||||
fill:
|
||||
props.type === 'area'
|
||||
? {
|
||||
colors: props.colors,
|
||||
type: 'gradient',
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: props.colors,
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
}
|
||||
: {},
|
||||
}
|
||||
})
|
||||
|
||||
const chart = ref(null)
|
||||
|
||||
const legendValues = ref(
|
||||
[...props.data].map((project, index) => {
|
||||
return { name: project.name, visible: true, color: props.colors[index] }
|
||||
}),
|
||||
)
|
||||
|
||||
const flipLegend = (legend, newVal) => {
|
||||
legend.visible = newVal
|
||||
chart.value.toggleSeries(legend.name)
|
||||
}
|
||||
|
||||
const resetChart = () => {
|
||||
if (!chart.value?.chart) return
|
||||
chart.value.updateSeries([...props.data])
|
||||
chart.value.updateOptions({
|
||||
xaxis: {
|
||||
categories: props.labels,
|
||||
},
|
||||
})
|
||||
chart.value.resetSeries()
|
||||
legendValues.value.forEach((legend) => {
|
||||
legend.visible = true
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetChart,
|
||||
flipLegend,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
z-index: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu),
|
||||
:deep(.apexcharts-tooltip),
|
||||
:deep(.apexcharts-yaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-divider) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-grid-borders) {
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip),
|
||||
:deep(.apexcharts-xaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-divider) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
|
||||
.apexcharts-xaxistooltip-text {
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip-left:after) {
|
||||
border-left-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip-left:before) {
|
||||
border-left-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxistooltip-bottom:after) {
|
||||
border-bottom-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxistooltip-bottom:before) {
|
||||
border-bottom-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu-item) {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: var(--gap-xs) var(--gap-sm) !important;
|
||||
|
||||
&:hover {
|
||||
transition: all 0.3s !important;
|
||||
color: var(--color-accent-contrast) !important;
|
||||
background: var(--color-brand) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-tooltip) {
|
||||
.bar-tooltip {
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
|
||||
.seperated-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: var(--gap-xl);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
.value {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
border: 2px solid var(--color-base);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-lg);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.checkbox) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-checkbox :deep(.checkbox.checked) {
|
||||
background-color: var(--color);
|
||||
}
|
||||
</style>
|
||||
@@ -1,974 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="analytics.error.value" class="universal-card">
|
||||
<h2>
|
||||
<span class="label__title">Error</span>
|
||||
</h2>
|
||||
<div>
|
||||
{{ analytics.error.value }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isInitialized || analytics.loading.value" class="universal-card">
|
||||
<h2>
|
||||
<span class="label__title">Loading analytics...</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div v-else class="graphs">
|
||||
<div class="graphs__vertical-bar">
|
||||
<client-only>
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.downloads"
|
||||
ref="tinyDownloadChart"
|
||||
:title="`Downloads`"
|
||||
color="var(--color-brand)"
|
||||
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
|
||||
:data="analytics.formattedData.value.downloads.chart.sumData"
|
||||
:labels="analytics.formattedData.value.downloads.chart.labels"
|
||||
suffix="<svg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='2'><path stroke-linecap='round' stroke-linejoin='round' d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' /></svg>"
|
||||
:class="`clickable chart-button-base button-base ${
|
||||
selectedChart === 'downloads'
|
||||
? 'chart-button-base__selected button-base__selected'
|
||||
: ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'downloads')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
<client-only>
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.views"
|
||||
ref="tinyViewChart"
|
||||
:title="`Views`"
|
||||
color="var(--color-blue)"
|
||||
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
|
||||
:data="analytics.formattedData.value.views.chart.sumData"
|
||||
:labels="analytics.formattedData.value.views.chart.labels"
|
||||
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
|
||||
:class="`clickable chart-button-base button-base ${
|
||||
selectedChart === 'views' ? 'chart-button-base__selected button-base__selected' : ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'views')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
<client-only>
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.revenue"
|
||||
ref="tinyRevenueChart"
|
||||
:title="`Revenue`"
|
||||
color="var(--color-purple)"
|
||||
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
|
||||
:data="analytics.formattedData.value.revenue.chart.sumData"
|
||||
:labels="analytics.formattedData.value.revenue.chart.labels"
|
||||
is-money
|
||||
:class="`clickable chart-button-base button-base ${
|
||||
selectedChart === 'revenue' ? 'chart-button-base__selected button-base__selected' : ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'revenue')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
</div>
|
||||
<div class="graphs__main-graph">
|
||||
<div class="universal-card">
|
||||
<div class="chart-controls">
|
||||
<h2>
|
||||
<span class="label__title">
|
||||
{{ formatCategoryHeader(selectedChart) }}
|
||||
</span>
|
||||
<span class="label__subtitle">
|
||||
{{ formattedCategorySubtitle }}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="chart-controls__buttons">
|
||||
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
|
||||
<PaletteIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'Download this data as CSV'" icon-only @click="onDownloadSetAsCSV">
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'Refresh the chart'" icon-only @click="resetCharts">
|
||||
<UpdatedIcon />
|
||||
</Button>
|
||||
<DropdownSelect
|
||||
v-model="selectedRange"
|
||||
class="range-dropdown"
|
||||
:options="ranges"
|
||||
name="Time range"
|
||||
:display-name="
|
||||
(o: RangeObject) => o?.getLabel([startDate, endDate]) ?? 'Loading...'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-area">
|
||||
<div class="chart">
|
||||
<client-only>
|
||||
<Chart
|
||||
v-if="analytics.formattedData.value.downloads && selectedChart === 'downloads'"
|
||||
ref="downloadsChart"
|
||||
type="line"
|
||||
name="Download data"
|
||||
:hide-legend="true"
|
||||
:data="analytics.formattedData.value.downloads.chart.data"
|
||||
:labels="analytics.formattedData.value.downloads.chart.labels"
|
||||
suffix="<svg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='2'><path stroke-linecap='round' stroke-linejoin='round' d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' /></svg>"
|
||||
:colors="
|
||||
isUsingProjectColors
|
||||
? analytics.formattedData.value.downloads.chart.colors
|
||||
: analytics.formattedData.value.downloads.chart.defaultColors
|
||||
"
|
||||
/>
|
||||
<Chart
|
||||
v-if="analytics.formattedData.value.views && selectedChart === 'views'"
|
||||
ref="viewsChart"
|
||||
type="line"
|
||||
name="View data"
|
||||
:hide-legend="true"
|
||||
:data="analytics.formattedData.value.views.chart.data"
|
||||
:labels="analytics.formattedData.value.views.chart.labels"
|
||||
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
|
||||
:colors="
|
||||
isUsingProjectColors
|
||||
? analytics.formattedData.value.views.chart.colors
|
||||
: analytics.formattedData.value.views.chart.defaultColors
|
||||
"
|
||||
/>
|
||||
<Chart
|
||||
v-if="analytics.formattedData.value.revenue && selectedChart === 'revenue'"
|
||||
ref="revenueChart"
|
||||
type="line"
|
||||
name="Revenue data"
|
||||
:hide-legend="true"
|
||||
:data="analytics.formattedData.value.revenue.chart.data"
|
||||
:labels="analytics.formattedData.value.revenue.chart.labels"
|
||||
is-money
|
||||
:colors="
|
||||
isUsingProjectColors
|
||||
? analytics.formattedData.value.revenue.chart.colors
|
||||
: analytics.formattedData.value.revenue.chart.defaultColors
|
||||
"
|
||||
/>
|
||||
</client-only>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<div class="legend__items">
|
||||
<template v-for="project in selectedDataSetProjects" :key="project">
|
||||
<button
|
||||
v-tooltip="project.title"
|
||||
:class="`legend__item button-base btn-transparent ${
|
||||
!projectIsOnDisplay(project.id) ? 'btn-dimmed' : ''
|
||||
}`"
|
||||
@click="
|
||||
() =>
|
||||
projectIsOnDisplay(project.id) &&
|
||||
analytics.validProjectIds.value.includes(project.id)
|
||||
? removeProjectFromDisplay(project.id)
|
||||
: addProjectToDisplay(project.id)
|
||||
"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
'--color-brand': isUsingProjectColors
|
||||
? intToRgba(project.color, project.id, theme.active ?? undefined)
|
||||
: getDefaultColor(project.id),
|
||||
}"
|
||||
class="legend__item__color"
|
||||
></div>
|
||||
<div class="legend__item__text">{{ project.title }}</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="country-data">
|
||||
<Card
|
||||
v-if="
|
||||
analytics.formattedData.value?.downloadsByCountry &&
|
||||
selectedChart === 'downloads' &&
|
||||
analytics.formattedData.value.downloadsByCountry.data.length > 0
|
||||
"
|
||||
class="country-downloads"
|
||||
>
|
||||
<label>
|
||||
<span class="label__title">Downloads by region</span>
|
||||
</label>
|
||||
<div class="country-values">
|
||||
<div
|
||||
v-for="[name, count] in analytics.formattedData.value.downloadsByCountry.data"
|
||||
:key="name"
|
||||
class="country-value"
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">
|
||||
<div
|
||||
class="country-flag flex select-none items-center justify-center bg-bg-raised font-extrabold text-secondary"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
:src="countryCodeToFlag(name)"
|
||||
:alt="`${countryCodeToName(name)}'s flag`"
|
||||
class="country-flag"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="country-text">
|
||||
<strong class="country-name"
|
||||
><template v-if="name.toLowerCase() === 'xx' || !name">Other</template>
|
||||
<template v-else>{{ countryCodeToName(name) }}</template>
|
||||
</strong>
|
||||
<span class="data-point">{{ formatNumber(count) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="
|
||||
formatPercent(count, analytics.formattedData.value.downloadsByCountry.sum)
|
||||
"
|
||||
class="percentage-bar"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: formatPercent(
|
||||
count,
|
||||
analytics.formattedData.value.downloadsByCountry.sum,
|
||||
),
|
||||
backgroundColor: 'var(--color-brand)',
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="
|
||||
analytics.formattedData.value?.viewsByCountry &&
|
||||
selectedChart === 'views' &&
|
||||
analytics.formattedData.value.viewsByCountry.data.length > 0
|
||||
"
|
||||
class="country-downloads"
|
||||
>
|
||||
<label>
|
||||
<span class="label__title">Page views by region</span>
|
||||
</label>
|
||||
<div class="country-values">
|
||||
<div
|
||||
v-for="[name, count] in analytics.formattedData.value.viewsByCountry.data"
|
||||
:key="name"
|
||||
class="country-value"
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">
|
||||
<div
|
||||
class="country-flag flex select-none items-center justify-center bg-bg-raised font-extrabold text-secondary"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
:src="countryCodeToFlag(name)"
|
||||
:alt="`${countryCodeToName(name)}'s flag`"
|
||||
class="country-flag"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="country-text">
|
||||
<strong class="country-name">
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">Other</template>
|
||||
<template v-else>{{ countryCodeToName(name) }}</template>
|
||||
</strong>
|
||||
<span class="data-point">{{ formatNumber(count) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="
|
||||
`${
|
||||
Math.round(
|
||||
(count / analytics.formattedData.value.viewsByCountry.sum) * 10000,
|
||||
) / 100
|
||||
}%`
|
||||
"
|
||||
class="percentage-bar"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: `${(count / analytics.formattedData.value.viewsByCountry.sum) * 100}%`,
|
||||
backgroundColor: 'var(--color-blue)',
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, PaletteIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { Button, Card, DropdownSelect } from '@modrinth/ui'
|
||||
import { formatCategoryHeader, formatMoney, formatNumber } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { UiChartsChart as Chart, UiChartsCompactChart as CompactChart } from '#components'
|
||||
import {
|
||||
analyticsSetToCSVString,
|
||||
countryCodeToFlag,
|
||||
countryCodeToName,
|
||||
formatPercent,
|
||||
getDefaultColor,
|
||||
intToRgba,
|
||||
} from '~/utils/analytics.js'
|
||||
|
||||
const router = useNativeRouter()
|
||||
const theme = useTheme()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
projects?: any[]
|
||||
/**
|
||||
* @deprecated Use `ranges` instead
|
||||
*/
|
||||
resoloutions?: Record<string, number>
|
||||
ranges?: RangeObject[]
|
||||
personal?: boolean
|
||||
}>(),
|
||||
{
|
||||
projects: undefined,
|
||||
resoloutions: () => defaultResoloutions,
|
||||
ranges: () => defaultRanges,
|
||||
personal: false,
|
||||
},
|
||||
)
|
||||
|
||||
const projects = ref(props.projects || [])
|
||||
|
||||
// const selectedChart = ref('downloads')
|
||||
const selectedChart = computed({
|
||||
get: () => {
|
||||
const id = (router.currentRoute.value.query?.chart as string | undefined) || 'downloads'
|
||||
// if the id is anything but the 3 charts we have or undefined, throw an error
|
||||
if (!['downloads', 'views', 'revenue'].includes(id)) {
|
||||
throw new Error(`Unknown chart ${id}`)
|
||||
}
|
||||
return id
|
||||
},
|
||||
set: (chart) => {
|
||||
router.push({
|
||||
query: {
|
||||
...router.currentRoute.value.query,
|
||||
chart,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Chart refs
|
||||
const downloadsChart = ref()
|
||||
const viewsChart = ref()
|
||||
const revenueChart = ref()
|
||||
const tinyDownloadChart = ref()
|
||||
const tinyViewChart = ref()
|
||||
const tinyRevenueChart = ref()
|
||||
|
||||
const selectedDisplayProjects = ref(props.projects || [])
|
||||
|
||||
const removeProjectFromDisplay = (id: string) => {
|
||||
selectedDisplayProjects.value = selectedDisplayProjects.value.filter((p) => p.id !== id)
|
||||
}
|
||||
|
||||
const addProjectToDisplay = (id: string) => {
|
||||
selectedDisplayProjects.value = [
|
||||
...selectedDisplayProjects.value,
|
||||
props.projects?.find((p) => p.id === id),
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
const projectIsOnDisplay = (id: string) => {
|
||||
return selectedDisplayProjects.value?.some((p) => p.id === id) ?? false
|
||||
}
|
||||
|
||||
const resetCharts = () => {
|
||||
downloadsChart.value?.resetChart()
|
||||
viewsChart.value?.resetChart()
|
||||
revenueChart.value?.resetChart()
|
||||
|
||||
tinyDownloadChart.value?.resetChart()
|
||||
tinyViewChart.value?.resetChart()
|
||||
tinyRevenueChart.value?.resetChart()
|
||||
}
|
||||
|
||||
const isUsingProjectColors = computed({
|
||||
get: () => {
|
||||
return (
|
||||
router.currentRoute.value.query?.colors === 'true' ||
|
||||
router.currentRoute.value.query?.colors === undefined
|
||||
)
|
||||
},
|
||||
set: (newValue) => {
|
||||
router.push({
|
||||
query: {
|
||||
...router.currentRoute.value.query,
|
||||
colors: newValue ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const startDate = ref(dayjs().startOf('day'))
|
||||
const endDate = ref(dayjs().endOf('day'))
|
||||
const timeResolution = ref(30)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
onBeforeMount(() => {
|
||||
// Load cached data and range from localStorage - cache.
|
||||
if (import.meta.client) {
|
||||
const rangeLabel = localStorage.getItem('analyticsSelectedRange')
|
||||
if (rangeLabel) {
|
||||
const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel)!
|
||||
|
||||
if (range !== undefined) {
|
||||
internalRange.value = range
|
||||
const ranges = range.getDates(dayjs())
|
||||
timeResolution.value = range.timeResolution
|
||||
startDate.value = ranges.startDate
|
||||
endDate.value = ranges.endDate
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (internalRange.value === null) {
|
||||
internalRange.value = props.ranges.find(
|
||||
(r) => r.getLabel([dayjs(), dayjs()]) === 'Previous 30 days',
|
||||
)!
|
||||
}
|
||||
|
||||
const ranges = selectedRange.value.getDates(dayjs())
|
||||
startDate.value = ranges.startDate
|
||||
endDate.value = ranges.endDate
|
||||
timeResolution.value = selectedRange.value.timeResolution
|
||||
|
||||
isInitialized.value = true
|
||||
})
|
||||
|
||||
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject)
|
||||
|
||||
const selectedRange = computed({
|
||||
get: () => {
|
||||
return internalRange.value
|
||||
},
|
||||
set: (newRange) => {
|
||||
const ranges = newRange.getDates(dayjs())
|
||||
startDate.value = ranges.startDate
|
||||
endDate.value = ranges.endDate
|
||||
timeResolution.value = newRange.timeResolution
|
||||
|
||||
internalRange.value = newRange
|
||||
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(
|
||||
'analyticsSelectedRange',
|
||||
internalRange.value?.getLabel([dayjs(), dayjs()]) ?? 'Previous 30 days',
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const analytics = useFetchAllAnalytics(
|
||||
resetCharts,
|
||||
projects,
|
||||
selectedDisplayProjects,
|
||||
props.personal,
|
||||
startDate,
|
||||
endDate,
|
||||
timeResolution,
|
||||
isInitialized,
|
||||
)
|
||||
|
||||
const formattedCategorySubtitle = computed(() => {
|
||||
return (
|
||||
selectedRange.value?.getLabel([dayjs(startDate.value), dayjs(endDate.value)]) ?? 'Loading...'
|
||||
)
|
||||
})
|
||||
|
||||
const selectedDataSet = computed(() => {
|
||||
switch (selectedChart.value) {
|
||||
case 'downloads':
|
||||
return analytics.totalData.value.downloads
|
||||
case 'views':
|
||||
return analytics.totalData.value.views
|
||||
case 'revenue':
|
||||
return analytics.totalData.value.revenue
|
||||
default:
|
||||
throw new Error(`Unknown chart ${selectedChart.value}`)
|
||||
}
|
||||
})
|
||||
const selectedDataSetProjects = computed(() => {
|
||||
return selectedDataSet.value.projectIds
|
||||
.map((id) => props.projects?.find((p) => p?.id === id))
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const downloadSelectedSetAsCSV = () => {
|
||||
const selectedChartName = selectedChart.value
|
||||
|
||||
const csv = analyticsSetToCSVString(selectedDataSet.value)
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `${selectedChartName}-data.csv`)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
|
||||
link.click()
|
||||
}
|
||||
|
||||
const onDownloadSetAsCSV = useClientTry(async () => await downloadSelectedSetAsCSV())
|
||||
const onToggleColors = () => {
|
||||
isUsingProjectColors.value = !isUsingProjectColors.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* @deprecated Use `ranges` instead
|
||||
*/
|
||||
const defaultResoloutions: Record<string, number> = {
|
||||
'5 minutes': 5,
|
||||
'30 minutes': 30,
|
||||
'An hour': 60,
|
||||
'12 hours': 720,
|
||||
'A day': 1440,
|
||||
'A week': 10080,
|
||||
}
|
||||
|
||||
type DateRange = { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs }
|
||||
|
||||
type RangeObject = {
|
||||
getLabel: (dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => string
|
||||
getDates: (currentDate: dayjs.Dayjs) => DateRange
|
||||
// A time resolution in minutes.
|
||||
timeResolution: number
|
||||
}
|
||||
|
||||
const defaultRanges: RangeObject[] = [
|
||||
{
|
||||
getLabel: () => 'Previous 30 minutes',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(30, 'minute'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Previous hour',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, 'hour'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 5,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Previous 12 hours',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(12, 'hour'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 12,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Previous 24 hours',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, 'day'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Today',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf('day'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Yesterday',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, 'day').startOf('day'),
|
||||
endDate: dayjs(currentDate).startOf('day').subtract(1, 'second'),
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'This week',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf('week').add(1, 'hour'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 360,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Last week',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, 'week').startOf('week').add(1, 'hour'),
|
||||
endDate: dayjs(currentDate).startOf('week').subtract(1, 'second'),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Previous 7 days',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf('day').subtract(7, 'day').add(1, 'hour'),
|
||||
endDate: currentDate.startOf('day'),
|
||||
}),
|
||||
timeResolution: 720,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'This month',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf('month').add(1, 'hour'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Last month',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, 'month').startOf('month').add(1, 'hour'),
|
||||
endDate: dayjs(currentDate).startOf('month').subtract(1, 'second'),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Previous 30 days',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf('day').subtract(30, 'day').add(1, 'hour'),
|
||||
endDate: currentDate.startOf('day'),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'This quarter',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf('quarter').add(1, 'hour'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Last quarter',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, 'quarter').startOf('quarter').add(1, 'hour'),
|
||||
endDate: dayjs(currentDate).startOf('quarter').subtract(1, 'second'),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'This year',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf('year'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 20160,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Last year',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, 'year').startOf('year'),
|
||||
endDate: dayjs(currentDate).startOf('year').subtract(1, 'second'),
|
||||
}),
|
||||
timeResolution: 20160,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Previous year',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, 'year'),
|
||||
endDate: dayjs(currentDate),
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'Previous two years',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(2, 'year'),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
{
|
||||
getLabel: () => 'All Time',
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(0),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.chart-controls__buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
* {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.label__subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.range-dropdown {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-md);
|
||||
|
||||
height: 100%;
|
||||
|
||||
.chart {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 24px;
|
||||
overflow: hidden;
|
||||
|
||||
max-width: 26ch;
|
||||
width: fit-content;
|
||||
|
||||
.legend__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
.legend__item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
width: 100%;
|
||||
|
||||
.legend__item__text {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.legend__item__color {
|
||||
height: var(--font-size-xs);
|
||||
width: var(--font-size-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-brand);
|
||||
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-transparent {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
color: var(--text-color);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.btn-dimmed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chart-button-base {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-button-base__selected {
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
0 0 0 2px var(--color-brand);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
.graphs {
|
||||
// Pages clip so we need to add a margin
|
||||
margin-left: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.graphs__vertical-bar {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
gap: 0.75rem;
|
||||
display: flex;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.country-flag-container {
|
||||
width: 40px;
|
||||
height: 27px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.country-flag {
|
||||
object-fit: cover;
|
||||
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.spark-data {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.country-data {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.country-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-divider);
|
||||
gap: var(--gap-md);
|
||||
padding: var(--gap-md);
|
||||
margin-top: var(--gap-md);
|
||||
overflow-y: auto;
|
||||
max-height: 24rem;
|
||||
}
|
||||
|
||||
.country-value {
|
||||
display: grid;
|
||||
grid-template-areas: 'flag text bar';
|
||||
grid-template-columns: auto 1fr 10rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.country-text {
|
||||
grid-area: text;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.percentage-bar {
|
||||
grid-area: bar;
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
background-color: var(--color-raised-bg);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chart-area {
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.chart {
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 0px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.graphs {
|
||||
margin-left: 0px;
|
||||
margin-top: 0px;
|
||||
|
||||
.graphs__vertical-bar {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.country-data {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.country-value {
|
||||
grid-template-columns: auto 1fr 5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,281 +0,0 @@
|
||||
<script setup>
|
||||
import { Card } from '@modrinth/ui'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
// let VueApexCharts
|
||||
// if (import.meta.client) {
|
||||
// VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
// }
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isMoney: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'var(--color-brand)',
|
||||
},
|
||||
})
|
||||
|
||||
// no grid lines, no toolbar, no legend, no data labels
|
||||
const chartOptions = {
|
||||
chart: {
|
||||
id: props.title,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
sparkline: {
|
||||
enabled: true,
|
||||
},
|
||||
parentHeightOffset: 0,
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2,
|
||||
},
|
||||
fill: {
|
||||
colors: [props.color],
|
||||
type: 'gradient',
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: [props.color],
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
colors: [props.color],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
const chart = ref(null)
|
||||
|
||||
const resetChart = () => {
|
||||
if (!chart.value?.chart) return
|
||||
chart.value.updateSeries([...props.data])
|
||||
chart.value.updateOptions({
|
||||
xaxis: {
|
||||
categories: props.labels,
|
||||
},
|
||||
})
|
||||
chart.value.resetSeries()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetChart,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="compact-chart">
|
||||
<h1 class="value">
|
||||
{{ value }}
|
||||
</h1>
|
||||
<div class="subtitle">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="chart">
|
||||
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.compact-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: var(--gap-xs);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-card);
|
||||
|
||||
color: var(--color-base);
|
||||
font-size: var(--font-size-nm);
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding-top: var(--gap-xl);
|
||||
padding-bottom: 0;
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
// width: calc(100% + 3rem);
|
||||
margin: 0 -1.5rem 0.25rem -1.5rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu),
|
||||
:deep(.apexcharts-tooltip),
|
||||
:deep(.apexcharts-yaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-divider) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-graphical) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-tooltip) {
|
||||
.bar-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-grid-borders) {
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxis) {
|
||||
line {
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-checkbox :deep(.checkbox.checked) {
|
||||
background-color: var(--color);
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
:stages="ctx.stageConfigs"
|
||||
:context="ctx"
|
||||
:breadcrumbs="!editingVersion"
|
||||
:close-on-click-outside="false"
|
||||
@hide="() => (modalOpen = false)"
|
||||
/>
|
||||
<DropArea
|
||||
|
||||
+15
-14
@@ -2,28 +2,29 @@
|
||||
<div
|
||||
class="flex h-11 items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
|
||||
>
|
||||
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center justify-start gap-2">
|
||||
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
|
||||
|
||||
<span v-tooltip="name || projectId" class="truncate font-semibold text-contrast">
|
||||
<span
|
||||
v-tooltip="name || projectId"
|
||||
class="min-w-0 max-w-fit flex-1 truncate font-semibold text-contrast"
|
||||
>
|
||||
{{ name || 'Unknown Project' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="versionNumber"
|
||||
v-tooltip="versionNumber"
|
||||
class="min-w-0 max-w-fit flex-1 truncate whitespace-nowrap text-sm font-medium"
|
||||
>
|
||||
{{ versionNumber }}
|
||||
</span>
|
||||
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
|
||||
{{ dependencyType }}
|
||||
</TagItem>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="versionName"
|
||||
v-tooltip="versionName"
|
||||
class="truncate whitespace-nowrap font-medium"
|
||||
:class="!hideRemove ? 'max-w-[35%]' : 'max-w-[50%]'"
|
||||
>
|
||||
{{ versionName }}
|
||||
</span>
|
||||
|
||||
<div v-if="!hideRemove" class="flex items-center justify-end gap-1">
|
||||
<div v-if="!hideRemove" class="flex shrink-0 items-center justify-end gap-1">
|
||||
<ButtonStyled size="standard" :circular="true">
|
||||
<button aria-label="Remove file" class="-mr-2 !shadow-none" @click="emitRemove">
|
||||
<XIcon aria-hidden="true" />
|
||||
@@ -43,12 +44,12 @@ const emit = defineEmits<{
|
||||
(e: 'remove'): void
|
||||
}>()
|
||||
|
||||
const { projectId, name, icon, dependencyType, versionName, hideRemove } = defineProps<{
|
||||
const { projectId, name, icon, dependencyType, versionNumber, hideRemove } = defineProps<{
|
||||
projectId: string
|
||||
name?: string
|
||||
icon?: string
|
||||
dependencyType: Labrinth.Versions.v2.DependencyType
|
||||
versionName?: string
|
||||
versionNumber?: string
|
||||
hideRemove?: boolean
|
||||
}>()
|
||||
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@
|
||||
:name="dependency.name"
|
||||
:icon="dependency.icon"
|
||||
:dependency-type="dependency.dependencyType"
|
||||
:version-name="dependency.versionName"
|
||||
:version-number="dependency.versionNumber"
|
||||
:hide-remove="disableRemove"
|
||||
@remove="() => removeDependency(index)"
|
||||
/>
|
||||
@@ -35,7 +35,7 @@ const addedDependencies = computed(() =>
|
||||
if (!dep.project_id) return null
|
||||
|
||||
const dependencyProject = dependencyProjects.value[dep.project_id]
|
||||
const versionName = dependencyVersions.value[dep.version_id || '']?.name ?? ''
|
||||
const versionNumber = dependencyVersions.value[dep.version_id || '']?.version_number ?? ''
|
||||
|
||||
if (!dependencyProject && projectsFetchLoading.value) return null
|
||||
|
||||
@@ -44,7 +44,7 @@ const addedDependencies = computed(() =>
|
||||
name: dependencyProject?.name,
|
||||
icon: dependencyProject?.icon_url,
|
||||
dependencyType: dep.dependency_type,
|
||||
versionName,
|
||||
versionNumber,
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
|
||||
+91
-21
@@ -3,11 +3,16 @@
|
||||
v-model="projectId"
|
||||
placeholder="Select project"
|
||||
:options="options"
|
||||
:searchable="true"
|
||||
:search-value="selectedProjectOption?.label"
|
||||
search-placeholder="Search by name or paste ID..."
|
||||
:no-options-message="searchLoading ? 'Loading...' : 'No results found'"
|
||||
:disable-search-filter="true"
|
||||
searchable
|
||||
disable-search-filter
|
||||
select-search-text-on-focus
|
||||
:show-chevron="false"
|
||||
@search-input="(query) => handleSearch(query)"
|
||||
@search-blur="handleSearchBlur"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -15,15 +20,37 @@
|
||||
import type { ComboboxOption } from '@modrinth/ui'
|
||||
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { defineAsyncComponent, h } from 'vue'
|
||||
import { defineAsyncComponent, h, markRaw, ref, watch } from 'vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const projectId = defineModel<string>()
|
||||
|
||||
const searchLoading = ref(false)
|
||||
const options = ref<ComboboxOption<string>[]>([])
|
||||
const selectedProjectOption = ref<ComboboxOption<string>>()
|
||||
const selectedProjectSearchQuery = ref('')
|
||||
|
||||
const { labrinth } = injectModrinthClient()
|
||||
let latestSearchQuery = ''
|
||||
|
||||
function hitToOption(hit: { title: string; project_id: string; icon_url?: string | null }) {
|
||||
return {
|
||||
label: hit.title,
|
||||
value: hit.project_id,
|
||||
icon: markRaw(
|
||||
defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: hit.icon_url,
|
||||
alt: hit.title,
|
||||
class: 'h-5 w-5 rounded',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const search = async (query: string) => {
|
||||
query = query.trim()
|
||||
@@ -53,34 +80,77 @@ const search = async (query: string) => {
|
||||
facets: [[`project_id:${query.replace(/[^a-zA-Z0-9]/g, '')}`]], // remove any non-alphanumeric characters
|
||||
})
|
||||
|
||||
options.value = [...resultsByProjectId.hits, ...results.hits].map((hit) => ({
|
||||
label: hit.title,
|
||||
value: hit.project_id,
|
||||
icon: defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: hit.icon_url,
|
||||
alt: hit.title,
|
||||
class: 'h-5 w-5 rounded',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
if (query !== latestSearchQuery) return
|
||||
|
||||
options.value = [...resultsByProjectId.hits, ...results.hits].map(hitToOption)
|
||||
} catch (error: any) {
|
||||
if (query !== latestSearchQuery) return
|
||||
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: error.data ? error.data.description : error,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
searchLoading.value = false
|
||||
|
||||
if (query === latestSearchQuery) {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const throttledSearch = useDebounceFn(search, 500)
|
||||
const throttledSearch = useDebounceFn(search, 250)
|
||||
|
||||
const runSearch = async (query: string, debounce: boolean) => {
|
||||
query = query.trim()
|
||||
latestSearchQuery = query
|
||||
|
||||
if (!query) {
|
||||
searchLoading.value = false
|
||||
options.value = []
|
||||
await throttledSearch(query)
|
||||
return
|
||||
}
|
||||
|
||||
searchLoading.value = true
|
||||
await (debounce ? throttledSearch(query) : search(query))
|
||||
}
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
searchLoading.value = true
|
||||
await throttledSearch(query)
|
||||
await runSearch(query, true)
|
||||
}
|
||||
|
||||
const handleSelect = (option: ComboboxOption<string>) => {
|
||||
selectedProjectOption.value = option
|
||||
selectedProjectSearchQuery.value = latestSearchQuery
|
||||
}
|
||||
|
||||
const handleSearchBlur = async () => {
|
||||
if (!projectId.value) return
|
||||
|
||||
const selectedOption =
|
||||
options.value.find((option) => option.value === projectId.value) ??
|
||||
(selectedProjectOption.value?.value === projectId.value
|
||||
? selectedProjectOption.value
|
||||
: undefined)
|
||||
if (!selectedOption) return
|
||||
|
||||
await runSearch(selectedProjectSearchQuery.value || selectedOption.label, false)
|
||||
|
||||
if (!options.value.some((option) => option.value === selectedOption.value)) {
|
||||
options.value = [selectedOption, ...options.value]
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, (value) => {
|
||||
if (!value) {
|
||||
selectedProjectOption.value = undefined
|
||||
selectedProjectSearchQuery.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const option = options.value.find((option) => option.value === value)
|
||||
if (option) {
|
||||
selectedProjectOption.value = option
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">Loaders <span class="text-red">*</span></span>
|
||||
<span class="font-semibold text-contrast">Loaders</span>
|
||||
|
||||
<Chips
|
||||
v-model="loaderGroup"
|
||||
@@ -27,8 +27,8 @@
|
||||
"
|
||||
:style="`--_color: var(--color-platform-${loader.name})`"
|
||||
>
|
||||
<div v-html="loader.icon"></div>
|
||||
{{ formatCategory(loader.name) }}
|
||||
<component :is="getLoaderIcon(loader.name)" v-if="getLoaderIcon(loader.name)" />
|
||||
<FormattedTag :tag="loader.name" enforce-type="loader" />
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,14 +40,15 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { Chips, TagItem } from '@modrinth/ui'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
import { getLoaderIcon } from '@modrinth/assets'
|
||||
import { Chips, FormattedTag, TagItem } from '@modrinth/ui'
|
||||
|
||||
const selectedLoaders = defineModel<string[]>({ default: [] })
|
||||
|
||||
const { loaders } = defineProps<{
|
||||
const { loaders, includeGeyser } = defineProps<{
|
||||
loaders: Labrinth.Tags.v2.Loader[]
|
||||
toggleLoader: (loader: string) => void
|
||||
includeGeyser?: boolean
|
||||
}>()
|
||||
|
||||
const loaderGroup = ref<GroupLabels>('mods')
|
||||
@@ -92,7 +93,7 @@ function groupLoaders(loaders: Labrinth.Tags.v2.Loader[]) {
|
||||
'bungeecord',
|
||||
'velocity',
|
||||
'waterfall',
|
||||
'geyser',
|
||||
...(includeGeyser ? ['geyser'] : []),
|
||||
]
|
||||
|
||||
const SHADER_SORT = ['optifine', 'iris', 'canvas', 'vanilla']
|
||||
|
||||
+13
-7
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="!noHeader" class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast">
|
||||
Minecraft versions <span class="text-red">*</span>
|
||||
</span>
|
||||
@@ -13,10 +13,13 @@
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div class="iconified-input w-full">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input v-model="searchQuery" type="text" placeholder="Search versions" />
|
||||
</div>
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
placeholder="Search versions"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex h-72 select-none flex-col gap-3 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
|
||||
>
|
||||
@@ -42,6 +45,7 @@
|
||||
versionType === 'all' && !group.isReleaseGroup ? 'w-max' : 'w-16',
|
||||
modelValue.includes(version) ? '!text-contrast' : '',
|
||||
]"
|
||||
:disabled="disabled"
|
||||
@click="() => handleToggleVersion(version)"
|
||||
@blur="
|
||||
() => {
|
||||
@@ -64,7 +68,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Chips } from '@modrinth/ui'
|
||||
import { ButtonStyled, Chips, StyledInput } from '@modrinth/ui'
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
@@ -73,6 +77,8 @@ type GameVersion = Labrinth.Tags.v2.GameVersion
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||
noHeader?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -155,7 +161,7 @@ function groupVersions(gameVersions: GameVersion[]) {
|
||||
|
||||
const groups: Record<string, string[]> = {}
|
||||
|
||||
let currentGroupKey = getSnapshotGroupKey(gameVersions.find((v) => v.major)?.version || '')
|
||||
let currentGroupKey: string
|
||||
|
||||
gameVersions.forEach((gameVersion) => {
|
||||
if (gameVersion.version_type === 'release') {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
:name="dependency.name"
|
||||
:icon="dependency.icon"
|
||||
:dependency-type="dependency.dependency_type"
|
||||
:version-name="dependency.versionName"
|
||||
:version-number="dependency.versionNumber"
|
||||
@on-add-suggestion="
|
||||
() =>
|
||||
handleAddSuggestion({
|
||||
|
||||
+17
-14
@@ -1,28 +1,31 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 rounded-xl border-2 border-dashed border-surface-5 px-4 py-1 text-button-text"
|
||||
class="flex h-11 items-center justify-between gap-2 rounded-xl border-2 border-dashed border-surface-5 px-4 py-1 text-button-text"
|
||||
>
|
||||
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center justify-start gap-2">
|
||||
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
|
||||
|
||||
<span v-tooltip="name || 'Unknown Project'" class="truncate font-semibold text-contrast">
|
||||
<span
|
||||
v-tooltip="name || 'Unknown Project'"
|
||||
class="min-w-0 max-w-fit flex-1 truncate font-semibold text-contrast"
|
||||
>
|
||||
{{ name || 'Unknown Project' }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="versionNumber"
|
||||
v-tooltip="versionNumber"
|
||||
class="min-w-0 max-w-fit flex-1 truncate whitespace-nowrap text-sm font-medium"
|
||||
>
|
||||
{{ versionNumber }}
|
||||
</span>
|
||||
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
|
||||
{{ dependencyType }}
|
||||
</TagItem>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="versionName"
|
||||
v-tooltip="versionName"
|
||||
class="max-w-[35%] truncate whitespace-nowrap font-medium"
|
||||
>
|
||||
{{ versionName }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<div class="flex shrink-0 items-center justify-end gap-1">
|
||||
<ButtonStyled size="standard" :circular="true" type="transparent">
|
||||
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
@@ -41,11 +44,11 @@ const emit = defineEmits<{
|
||||
(e: 'onAddSuggestion'): void
|
||||
}>()
|
||||
|
||||
const { name, icon, dependencyType, versionName } = defineProps<{
|
||||
const { name, icon, dependencyType, versionNumber } = defineProps<{
|
||||
name?: string
|
||||
icon?: string
|
||||
dependencyType: Labrinth.Versions.v2.DependencyType
|
||||
versionName?: string
|
||||
versionNumber?: string
|
||||
}>()
|
||||
|
||||
function emitAddSuggestion() {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<template>
|
||||
<Tabs
|
||||
v-if="editingVersion"
|
||||
value="add-files"
|
||||
:tabs="editTabs"
|
||||
class="mb-5 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
|
||||
@change="setEditTab"
|
||||
/>
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<template
|
||||
v-if="handlingNewFiles || !(filesToAdd.length || draftVersion.existing_files?.length)"
|
||||
@@ -91,6 +98,8 @@ import {
|
||||
defineMessages,
|
||||
DropzoneFileInput,
|
||||
injectProjectPageContext,
|
||||
Tabs,
|
||||
type TabsTab,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { acceptFileFromProjectType } from '@modrinth/utils'
|
||||
@@ -110,10 +119,21 @@ const {
|
||||
swapPrimaryFile,
|
||||
replacePrimaryFile,
|
||||
editingVersion,
|
||||
modal,
|
||||
primaryFile,
|
||||
handleNewFiles,
|
||||
} = injectManageVersionContext()
|
||||
|
||||
const editTabs: TabsTab[] = [
|
||||
{ label: 'Metadata', value: 'metadata' },
|
||||
{ label: 'Details', value: 'add-details' },
|
||||
{ label: 'Files', value: 'add-files' },
|
||||
]
|
||||
|
||||
function setEditTab(tab: TabsTab) {
|
||||
modal.value?.setStage(tab.value)
|
||||
}
|
||||
|
||||
function handleRemoveFile(index: number) {
|
||||
filesToAdd.value.splice(index, 1)
|
||||
}
|
||||
|
||||
+43
-167
@@ -1,182 +1,58 @@
|
||||
<template>
|
||||
<div class="flex w-full max-w-full flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<span class="font-semibold text-contrast">Add dependency</span>
|
||||
<div class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 p-4">
|
||||
<div class="grid gap-2.5">
|
||||
<span class="font-semibold text-contrast">Project</span>
|
||||
<DependencySelect v-model="newDependencyProjectId" />
|
||||
</div>
|
||||
<div class="flex w-full max-w-full flex-col gap-3">
|
||||
<div class="grid gap-2.5">
|
||||
<span class="font-semibold text-contrast">Project</span>
|
||||
<DependencySelect v-model="newDependencyProjectId" />
|
||||
</div>
|
||||
|
||||
<template v-if="newDependencyProjectId">
|
||||
<div class="grid gap-2.5">
|
||||
<span class="font-semibold text-contrast"> Version </span>
|
||||
<Combobox
|
||||
v-model="newDependencyVersionId"
|
||||
placeholder="Select version"
|
||||
:options="[{ label: 'Any version', value: null }, ...newDependencyVersions]"
|
||||
:searchable="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5">
|
||||
<span class="font-semibold text-contrast"> Dependency relation </span>
|
||||
<Combobox
|
||||
v-model="newDependencyType"
|
||||
placeholder="Select dependency type"
|
||||
:options="[
|
||||
{ label: 'Required', value: 'required' },
|
||||
{ label: 'Optional', value: 'optional' },
|
||||
{ label: 'Incompatible', value: 'incompatible' },
|
||||
{ label: 'Embedded', value: 'embedded' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="green">
|
||||
<button
|
||||
class="self-start"
|
||||
:disabled="!newDependencyProjectId"
|
||||
@click="
|
||||
() =>
|
||||
addDependency(
|
||||
toRaw({
|
||||
project_id: newDependencyProjectId,
|
||||
version_id: newDependencyVersionId || undefined,
|
||||
dependency_type: newDependencyType,
|
||||
}),
|
||||
)
|
||||
"
|
||||
>
|
||||
Add Dependency
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-if="newDependencyProjectId">
|
||||
<div class="grid gap-2.5">
|
||||
<span class="font-semibold text-contrast"> Version </span>
|
||||
<Combobox
|
||||
v-model="newDependencyVersionId"
|
||||
placeholder="Select version"
|
||||
:options="newDependencyVersionOptions"
|
||||
:search-value="selectedNewDependencyVersionLabel"
|
||||
:searchable="true"
|
||||
:select-search-text-on-focus="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-4">
|
||||
<span class="font-semibold text-contrast">Suggested dependencies</span>
|
||||
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
|
||||
</div>
|
||||
|
||||
<div v-if="addedDependencies.length" class="flex flex-col gap-4">
|
||||
<span class="font-semibold text-contrast">Added dependencies</span>
|
||||
<DependenciesList />
|
||||
</div>
|
||||
<div class="grid gap-2.5">
|
||||
<span class="font-semibold text-contrast"> Dependency relation </span>
|
||||
<Combobox
|
||||
v-model="newDependencyType"
|
||||
placeholder="Select dependency type"
|
||||
:options="[
|
||||
{ label: 'Required', value: 'required' },
|
||||
{ label: 'Optional', value: 'optional' },
|
||||
{ label: 'Incompatible', value: 'incompatible' },
|
||||
{ label: 'Embedded', value: 'embedded' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
import type { ComboboxOption } from '@modrinth/ui/src/components/base/Combobox.vue'
|
||||
import { Combobox } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DependencySelect from '~/components/ui/create-project-version/components/DependencySelect.vue'
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
|
||||
import DependenciesList from '../components/DependenciesList.vue'
|
||||
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
|
||||
const { newDependencyProjectId, newDependencyType, newDependencyVersionId, newDependencyVersions } =
|
||||
injectManageVersionContext()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
const {
|
||||
draftVersion,
|
||||
dependencyProjects,
|
||||
dependencyVersions,
|
||||
projectsFetchLoading,
|
||||
visibleSuggestedDependencies,
|
||||
} = injectManageVersionContext()
|
||||
|
||||
const errorNotification = (err: any) => {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
const newDependencyProjectId = ref<string>()
|
||||
const newDependencyType = ref<Labrinth.Versions.v2.DependencyType>('required')
|
||||
const newDependencyVersionId = ref<string | null>(null)
|
||||
|
||||
const newDependencyVersions = ref<ComboboxOption<string>[]>([])
|
||||
|
||||
// reset to defaults when select different project
|
||||
watch(newDependencyProjectId, async () => {
|
||||
newDependencyVersionId.value = null
|
||||
newDependencyType.value = 'required'
|
||||
|
||||
if (!newDependencyProjectId.value) {
|
||||
newDependencyVersions.value = []
|
||||
} else {
|
||||
try {
|
||||
const versions = await labrinth.versions_v3.getProjectVersions(newDependencyProjectId.value)
|
||||
newDependencyVersions.value = versions.map((version) => ({
|
||||
label: version.name,
|
||||
value: version.id,
|
||||
}))
|
||||
} catch (error: any) {
|
||||
errorNotification(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const addedDependencies = computed(() =>
|
||||
(draftVersion.value.dependencies || [])
|
||||
.map((dep) => {
|
||||
if (!dep.project_id) return null
|
||||
|
||||
const dependencyProject = dependencyProjects.value[dep.project_id]
|
||||
const versionName = dependencyVersions.value[dep.version_id || '']?.name ?? ''
|
||||
|
||||
if (!dependencyProject && projectsFetchLoading.value) return null
|
||||
|
||||
return {
|
||||
projectId: dep.project_id,
|
||||
name: dependencyProject?.name,
|
||||
icon: dependencyProject?.icon_url,
|
||||
dependencyType: dep.dependency_type,
|
||||
versionName,
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
const newDependencyVersionOptions = computed(() => [
|
||||
{ label: 'Any version', value: null },
|
||||
...newDependencyVersions.value,
|
||||
])
|
||||
const selectedNewDependencyVersionLabel = computed(
|
||||
() =>
|
||||
newDependencyVersionOptions.value.find(
|
||||
(option) => option.value === newDependencyVersionId.value,
|
||||
)?.label,
|
||||
)
|
||||
|
||||
const addDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
|
||||
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
|
||||
|
||||
const alreadyAdded = draftVersion.value.dependencies.some((existing) => {
|
||||
if (existing.project_id !== dependency.project_id) return false
|
||||
if (!existing.version_id && !dependency.version_id) return true
|
||||
return existing.version_id === dependency.version_id
|
||||
})
|
||||
|
||||
if (alreadyAdded) {
|
||||
addNotification({
|
||||
title: 'Dependency already added',
|
||||
text: 'You cannot add the same dependency twice.',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
projectsFetchLoading.value = true
|
||||
draftVersion.value.dependencies.push(dependency)
|
||||
newDependencyProjectId.value = undefined
|
||||
}
|
||||
|
||||
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
|
||||
draftVersion.value.dependencies?.push({
|
||||
project_id: dependency.project_id,
|
||||
version_id: dependency.version_id,
|
||||
dependency_type: dependency.dependency_type,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<template>
|
||||
<Tabs
|
||||
v-if="editingVersion"
|
||||
value="add-details"
|
||||
:tabs="editTabs"
|
||||
class="mb-5 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
|
||||
@change="setEditTab"
|
||||
/>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
@@ -10,37 +17,36 @@
|
||||
:never-empty="true"
|
||||
:capitalize="true"
|
||||
:disabled="isUploading"
|
||||
hide-checkmark-icon
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
Version number <span class="text-red">*</span>
|
||||
</span>
|
||||
<input
|
||||
<StyledInput
|
||||
id="version-number"
|
||||
v-model="draftVersion.version_number"
|
||||
:disabled="isUploading"
|
||||
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
maxlength="32"
|
||||
:maxlength="32"
|
||||
/>
|
||||
<span> The version number differentiates this specific version from others. </span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast"> Version subtitle </span>
|
||||
<input
|
||||
<StyledInput
|
||||
id="version-number"
|
||||
v-model="draftVersion.name"
|
||||
placeholder="Enter subtitle..."
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
maxlength="256"
|
||||
:maxlength="256"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast"> Version changlog </span>
|
||||
<span class="font-semibold text-contrast"> Version changelog </span>
|
||||
|
||||
<div class="w-full">
|
||||
<MarkdownEditor
|
||||
@@ -55,12 +61,22 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Chips, MarkdownEditor } from '@modrinth/ui'
|
||||
import { Chips, MarkdownEditor, StyledInput, Tabs, type TabsTab } from '@modrinth/ui'
|
||||
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
|
||||
const { draftVersion, isUploading } = injectManageVersionContext()
|
||||
const { draftVersion, isUploading, editingVersion, modal } = injectManageVersionContext()
|
||||
|
||||
const editTabs: TabsTab[] = [
|
||||
{ label: 'Metadata', value: 'metadata' },
|
||||
{ label: 'Details', value: 'add-details' },
|
||||
{ label: 'Files', value: 'add-files' },
|
||||
]
|
||||
|
||||
function setEditTab(tab: TabsTab) {
|
||||
modal.value?.setStage(tab.value)
|
||||
}
|
||||
|
||||
async function onImageUpload(file: File) {
|
||||
const response = await useImageUpload(file, { context: 'version' })
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div class="sm:w-[512px]">
|
||||
<EnvironmentSelector v-model="draftVersion.environment" />
|
||||
</div>
|
||||
<EnvironmentSelector v-model="draftVersion.environment" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-model="draftVersion.loaders"
|
||||
:loaders="generatedState.loaders"
|
||||
:toggle-loader="toggleLoader"
|
||||
:include-geyser="includeGeyser"
|
||||
/>
|
||||
|
||||
<div v-if="draftVersion.loaders.length" class="space-y-1">
|
||||
@@ -29,8 +30,8 @@
|
||||
class="border !border-solid border-surface-5 !transition-all hover:bg-button-bgHover hover:no-underline"
|
||||
:style="`--_color: var(--color-platform-${loader.name})`"
|
||||
>
|
||||
<div v-html="loader.icon"></div>
|
||||
{{ formatCategory(loader.name) }}
|
||||
<component :is="getLoaderIcon(loader.name)" v-if="getLoaderIcon(loader.name)" />
|
||||
<FormattedTag :tag="loader.name" enforce-type="loader" />
|
||||
<XIcon class="text-secondary" />
|
||||
</TagItem>
|
||||
</template>
|
||||
@@ -41,9 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, TagItem } from '@modrinth/ui'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
import { getLoaderIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, FormattedTag, TagItem } from '@modrinth/ui'
|
||||
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
|
||||
@@ -53,7 +53,9 @@ const generatedState = useGeneratedState()
|
||||
|
||||
const loaders = computed(() => generatedState.value.loaders)
|
||||
|
||||
const { draftVersion } = injectManageVersionContext()
|
||||
const { draftVersion, inferredVersionData } = injectManageVersionContext()
|
||||
|
||||
const includeGeyser = computed(() => inferredVersionData.value?.loaders?.includes('geyser'))
|
||||
|
||||
const toggleLoader = (loader: string) => {
|
||||
if (draftVersion.value.loaders.includes(loader)) {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<template>
|
||||
<Tabs
|
||||
v-if="editingVersion"
|
||||
value="metadata"
|
||||
:tabs="editTabs"
|
||||
class="mb-3 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
|
||||
@change="setEditTab"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div v-if="!editingVersion" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -72,12 +79,18 @@
|
||||
class="border !border-solid border-surface-5 hover:no-underline"
|
||||
:style="`--_color: var(--color-platform-${loader.name})`"
|
||||
>
|
||||
<div v-html="loader.icon"></div>
|
||||
{{ formatCategory(loader.name) }}
|
||||
<component :is="getLoaderIcon(loader.name)" v-if="getLoaderIcon(loader.name)" />
|
||||
<FormattedTag :tag="loader.name" enforce-type="loader" />
|
||||
</TagItem>
|
||||
</template>
|
||||
|
||||
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
|
||||
<TagItem
|
||||
v-if="!draftVersionLoaders.length && projectType === 'modpack'"
|
||||
class="border !border-solid border-surface-5 hover:no-underline"
|
||||
>
|
||||
No mod loader
|
||||
</TagItem>
|
||||
<span v-else-if="!draftVersionLoaders.length">No loaders selected.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,40 +160,32 @@
|
||||
</template>
|
||||
|
||||
<template v-if="!noDependenciesProject">
|
||||
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast"> Suggested dependencies </span>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast"> Dependencies </span>
|
||||
|
||||
<ButtonStyled type="transparent" size="standard">
|
||||
<button @click="editDependencies">
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
|
||||
</div>
|
||||
<ButtonStyled type="transparent" size="standard">
|
||||
<button @click="addDependency">
|
||||
<PlusIcon />
|
||||
Add dependency
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!visibleSuggestedDependencies.length || draftVersion.dependencies?.length"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast"> Dependencies </span>
|
||||
|
||||
<ButtonStyled type="transparent" size="standard">
|
||||
<button @click="editDependencies">
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
|
||||
<DependenciesList />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
|
||||
<span class="text-sm font-medium">No dependencies added.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
|
||||
<DependenciesList disable-remove />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
|
||||
<span class="text-sm font-medium">No dependencies added.</span>
|
||||
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium"> Suggested </span>
|
||||
</div>
|
||||
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -189,16 +194,18 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { EditIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { EditIcon, getLoaderIcon, PlusIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
ENVIRONMENTS_COPY,
|
||||
FormattedTag,
|
||||
injectProjectPageContext,
|
||||
Tabs,
|
||||
type TabsTab,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
@@ -223,6 +230,17 @@ const { projectV2 } = injectProjectPageContext()
|
||||
|
||||
const generatedState = useGeneratedState()
|
||||
const loaders = computed(() => generatedState.value.loaders)
|
||||
|
||||
const editTabs: TabsTab[] = [
|
||||
{ label: 'Metadata', value: 'metadata' },
|
||||
{ label: 'Details', value: 'add-details' },
|
||||
{ label: 'Files', value: 'add-files' },
|
||||
]
|
||||
|
||||
function setEditTab(tab: TabsTab) {
|
||||
modal.value?.setStage(tab.value)
|
||||
}
|
||||
|
||||
const isModpack = computed(() => projectType.value === 'modpack')
|
||||
const isResourcePack = computed(
|
||||
() =>
|
||||
@@ -249,7 +267,7 @@ const editEnvironment = () => {
|
||||
const editFiles = () => {
|
||||
modal.value?.setStage('from-details-files')
|
||||
}
|
||||
const editDependencies = () => {
|
||||
const addDependency = () => {
|
||||
modal.value?.setStage('from-details-dependencies')
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
<StyledInput
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:maxlength="64"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
autocomplete="off"
|
||||
:disabled="hasHitLimit"
|
||||
@@ -26,27 +25,26 @@
|
||||
}}</span>
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="additional-information"
|
||||
v-model="description"
|
||||
maxlength="256"
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="additional-information"
|
||||
v-model="description"
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.collectionInfo, { count: projectIds.length }) }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled class="w-24">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand" class="w-36">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="hasHitLimit" @click="create">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createCollection) }}
|
||||
@@ -60,9 +58,11 @@
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
@@ -102,18 +102,10 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'Your new collection will be created as a public collection with {count, plural, =0 {no projects} one {# project} other {# projects}}.',
|
||||
},
|
||||
cancel: {
|
||||
id: 'create.collection.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
createCollection: {
|
||||
id: 'create.collection.create-collection',
|
||||
defaultMessage: 'Create collection',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'create.collection.error-title',
|
||||
defaultMessage: 'An error occurred',
|
||||
},
|
||||
})
|
||||
|
||||
const name = ref('')
|
||||
@@ -150,7 +142,7 @@ async function create() {
|
||||
await router.push(`/collection/${result.id}`)
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.errorTitle),
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err?.data?.description || err?.message || err,
|
||||
type: 'error',
|
||||
})
|
||||
@@ -179,10 +171,6 @@ defineExpose({
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-top: var(--gap-md);
|
||||
}
|
||||
|
||||
@@ -42,10 +42,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MessageIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -97,35 +105,26 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
interface UserLimits {
|
||||
current: number
|
||||
max: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'project' | 'org' | 'collection'
|
||||
}>()
|
||||
|
||||
const model = defineModel<boolean>()
|
||||
|
||||
const apiEndpoint = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'project':
|
||||
return 'limits/projects'
|
||||
case 'org':
|
||||
return 'limits/organizations'
|
||||
case 'collection':
|
||||
return 'limits/collections'
|
||||
default:
|
||||
return 'limits/projects'
|
||||
}
|
||||
const { data: limits } = useQuery({
|
||||
queryKey: computed(() => ['limits', props.type]),
|
||||
queryFn: () => {
|
||||
switch (props.type) {
|
||||
case 'org':
|
||||
return client.labrinth.limits_v3.getOrganizationLimits()
|
||||
case 'collection':
|
||||
return client.labrinth.limits_v3.getCollectionLimits()
|
||||
default:
|
||||
return client.labrinth.limits_v3.getProjectLimits()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const { data: limits } = await useAsyncData<UserLimits | undefined>(
|
||||
`limits-${props.type}`,
|
||||
() => useBaseFetch(apiEndpoint.value, { apiVersion: 3 }) as Promise<UserLimits>,
|
||||
)
|
||||
|
||||
const typeName = computed<{ singular: string; plural: string }>(() => {
|
||||
switch (props.type) {
|
||||
case 'project':
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
<StyledInput
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:maxlength="64"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
autocomplete="off"
|
||||
:disabled="hasHitLimit"
|
||||
@input="updateSlug"
|
||||
@update:model-value="updateSlug"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -29,14 +28,13 @@
|
||||
</label>
|
||||
<div class="text-input-wrapper">
|
||||
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
|
||||
<input
|
||||
<StyledInput
|
||||
id="slug"
|
||||
v-model="slug"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="hasHitLimit"
|
||||
@input="setManualSlug"
|
||||
@update:model-value="setManualSlug"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,27 +46,26 @@
|
||||
</span>
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="additional-information"
|
||||
v-model="description"
|
||||
maxlength="256"
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="additional-information"
|
||||
v-model="description"
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.ownershipInfo) }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled class="w-24">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand" class="w-40">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="hasHitLimit" @click="createOrganization">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createOrganization) }}
|
||||
@@ -83,9 +80,11 @@
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
@@ -130,18 +129,10 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'You will be the owner of this organization, but you can invite other members and transfer ownership at any time.',
|
||||
},
|
||||
cancel: {
|
||||
id: 'create.organization.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
createOrganization: {
|
||||
id: 'create.organization.create-organization',
|
||||
defaultMessage: 'Create organization',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'create.organization.error-title',
|
||||
defaultMessage: 'An error occurred',
|
||||
},
|
||||
})
|
||||
|
||||
const name = ref<string>('')
|
||||
@@ -172,7 +163,7 @@ async function createOrganization(): Promise<void> {
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
addNotification({
|
||||
title: formatMessage(messages.errorTitle),
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
@@ -221,10 +212,6 @@ defineExpose({
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-top: var(--gap-md);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,87 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="min-w-md flex max-w-md flex-col gap-3">
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
projectType === 'server'
|
||||
? formatMessage(messages.serverProjectTitle)
|
||||
: formatMessage(messages.title)
|
||||
"
|
||||
:max-width="'550px'"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<CreateLimitAlert v-model="hasHitLimit" type="project" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="name">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="project-type">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.typeLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
<Combobox
|
||||
id="project-type"
|
||||
v-model="projectType"
|
||||
name="project-type"
|
||||
:options="projectTypeOptions"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="name">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:maxlength="64"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
autocomplete="off"
|
||||
:disabled="hasHitLimit"
|
||||
@input="updatedName()"
|
||||
@update:model-value="updatedName()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="slug">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.urlLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="text-input-wrapper">
|
||||
<label for="slug" class="flex flex-col gap-2.5">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.urlLabel) }}
|
||||
</span>
|
||||
<div class="text-input-wrapper !w-full">
|
||||
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
|
||||
<input
|
||||
<StyledInput
|
||||
id="slug"
|
||||
v-model="slug"
|
||||
:maxlength="64"
|
||||
class="w-full"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="hasHitLimit"
|
||||
@input="manualSlug = true"
|
||||
@update:model-value="manualSlug = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.visibilityLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="owner">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.ownerLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
id="owner"
|
||||
v-model="owner"
|
||||
name="owner"
|
||||
:options="[userOption, ...ownerOptions]"
|
||||
searchable
|
||||
:disabled="hasHitLimit"
|
||||
show-icon-in-selected
|
||||
/>
|
||||
<span>{{ formatMessage(messages.ownerDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.visibilityLabel) }}
|
||||
</span>
|
||||
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
|
||||
</label>
|
||||
<Chips
|
||||
id="visibility"
|
||||
@@ -56,34 +91,33 @@
|
||||
:capitalize="false"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="additional-information" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.summaryLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="additional-information"
|
||||
v-model="description"
|
||||
maxlength="256"
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="additional-information"
|
||||
v-model="description"
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled class="w-24">
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="cancel">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand" class="w-32">
|
||||
<button :disabled="hasHitLimit" @click="createProject">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="missingFieldsTooltip" :disabled="disableCreate" @click="createProject">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createProject) }}
|
||||
</button>
|
||||
@@ -93,28 +127,76 @@
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, defineAsyncComponent, h } from 'vue'
|
||||
|
||||
import CreateLimitAlert from './CreateLimitAlert.vue'
|
||||
|
||||
type ProjectTypes = 'server' | 'project'
|
||||
interface VisibilityOption {
|
||||
actual: Labrinth.Projects.v2.ProjectStatus
|
||||
display: string
|
||||
}
|
||||
interface ShowOptions {
|
||||
type?: 'server' | 'project'
|
||||
}
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
const auth = (await useAuth()) as Ref<{
|
||||
user: { id: string; username: string; avatar_url: string } | null
|
||||
}>
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'create.project.title',
|
||||
defaultMessage: 'Creating a project',
|
||||
},
|
||||
serverProjectTitle: {
|
||||
id: 'create.project.server-project-title',
|
||||
defaultMessage: 'Creating a server project',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'create.project.type-label',
|
||||
defaultMessage: 'Type',
|
||||
},
|
||||
typeProject: {
|
||||
id: 'create.project.type-project',
|
||||
defaultMessage: 'Project',
|
||||
},
|
||||
typeServer: {
|
||||
id: 'create.project.type-server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
ownerLabel: {
|
||||
id: 'create.project.owner-label',
|
||||
defaultMessage: 'Owner',
|
||||
},
|
||||
ownerDescription: {
|
||||
id: 'create.project.owner-description',
|
||||
defaultMessage: `Set the project owner as yourself or an organization you're a member of.`,
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'create.project.name-label',
|
||||
defaultMessage: 'Name',
|
||||
@@ -127,10 +209,6 @@ const messages = defineMessages({
|
||||
id: 'create.project.url-label',
|
||||
defaultMessage: 'URL',
|
||||
},
|
||||
visibilityLabel: {
|
||||
id: 'create.project.visibility-label',
|
||||
defaultMessage: 'Visibility',
|
||||
},
|
||||
visibilityDescription: {
|
||||
id: 'create.project.visibility-description',
|
||||
defaultMessage: 'The visibility of your project after it has been approved.',
|
||||
@@ -147,18 +225,10 @@ const messages = defineMessages({
|
||||
id: 'create.project.summary-placeholder',
|
||||
defaultMessage: 'This project adds...',
|
||||
},
|
||||
cancel: {
|
||||
id: 'create.project.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
createProject: {
|
||||
id: 'create.project.create-project',
|
||||
defaultMessage: 'Create project',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'create.project.error-title',
|
||||
defaultMessage: 'An error occurred',
|
||||
},
|
||||
visibilityPublic: {
|
||||
id: 'create.project.visibility-public',
|
||||
defaultMessage: 'Public',
|
||||
@@ -171,24 +241,38 @@ const messages = defineMessages({
|
||||
id: 'create.project.visibility-private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
organizationId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
missingFieldsTooltip: {
|
||||
id: 'create.project.missing-fields-tooltip',
|
||||
defaultMessage: 'Missing fields: {fields}',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
const props = defineProps<{
|
||||
organizationId?: string | null
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const hasHitLimit = ref(false)
|
||||
|
||||
const name = ref('')
|
||||
const slug = ref('')
|
||||
const description = ref('')
|
||||
const manualSlug = ref(false)
|
||||
const visibilities = ref([
|
||||
const projectType = ref<ProjectTypes>('project')
|
||||
const projectTypeOptions = computed<ComboboxOption<ProjectTypes>[]>(() => [
|
||||
{
|
||||
value: 'project',
|
||||
label: formatMessage(messages.typeProject),
|
||||
},
|
||||
{
|
||||
value: 'server',
|
||||
label: formatMessage(messages.typeServer),
|
||||
},
|
||||
])
|
||||
const ownerOptions = ref<ComboboxOption<string>[]>([])
|
||||
const owner = ref<string | undefined>('self')
|
||||
const organizations = ref<Labrinth.Projects.v3.Organization[]>([])
|
||||
const visibilities = ref<VisibilityOption[]>([
|
||||
{
|
||||
actual: 'approved',
|
||||
display: formatMessage(messages.visibilityPublic),
|
||||
@@ -202,10 +286,88 @@ const visibilities = ref([
|
||||
display: formatMessage(messages.visibilityPrivate),
|
||||
},
|
||||
])
|
||||
const visibility = ref(visibilities.value[0])
|
||||
const visibility = ref<VisibilityOption>(visibilities.value[0])
|
||||
|
||||
const disableCreate = computed(() => {
|
||||
if (hasHitLimit.value) return true
|
||||
if (!name.value.trim() || !slug.value.trim()) return true
|
||||
if (description.value.trim().length < 3) return true
|
||||
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
|
||||
return true
|
||||
return false
|
||||
})
|
||||
|
||||
const missingFieldsTooltip = computed(() => {
|
||||
const missingFields = []
|
||||
if (!name.value.trim()) missingFields.push(formatMessage(messages.nameLabel))
|
||||
if (!slug.value.trim()) missingFields.push(formatMessage(messages.urlLabel))
|
||||
if (description.value.trim().length < 3) missingFields.push(formatMessage(messages.summaryLabel))
|
||||
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
|
||||
missingFields.push(formatMessage(messages.ownerLabel))
|
||||
|
||||
if (missingFields.length === 0) return ''
|
||||
return formatMessage(messages.missingFieldsTooltip, {
|
||||
fields: missingFields.join(', '),
|
||||
})
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
modal.value.hide()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const userOption = computed(() => ({
|
||||
value: 'self',
|
||||
label: auth.value.user?.username || 'Unknown user',
|
||||
icon: auth.value.user?.avatar_url
|
||||
? markRaw(
|
||||
defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: auth.value.user?.avatar_url,
|
||||
alt: 'User Avatar',
|
||||
class: 'h-5 w-5 rounded-full',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
async function fetchOrganizations() {
|
||||
if (!auth.value.user?.id) return
|
||||
|
||||
try {
|
||||
const orgs = (await useBaseFetch(`user/${auth.value.user.id}/organizations`, {
|
||||
apiVersion: 3,
|
||||
})) as Labrinth.Projects.v3.Organization[]
|
||||
|
||||
organizations.value = orgs || []
|
||||
|
||||
ownerOptions.value = organizations.value.map((org) => ({
|
||||
value: org.id,
|
||||
label: org.name,
|
||||
icon: org.icon_url
|
||||
? markRaw(
|
||||
defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: org.icon_url,
|
||||
alt: `${org.name} Icon`,
|
||||
class: 'h-5 w-5 rounded',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}))
|
||||
if (props.organizationId) owner.value = props.organizationId
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch organizations:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
@@ -213,9 +375,7 @@ async function createProject() {
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const projectData = {
|
||||
const projectData: Labrinth.Projects.v2.CreateProjectBase = {
|
||||
title: name.value.trim(),
|
||||
project_type: 'mod',
|
||||
slug: slug.value,
|
||||
@@ -225,8 +385,8 @@ async function createProject() {
|
||||
initial_versions: [],
|
||||
team_members: [
|
||||
{
|
||||
user_id: auth.value.user.id,
|
||||
name: auth.value.user.username,
|
||||
user_id: auth.value.user?.id,
|
||||
name: auth.value.user?.username,
|
||||
role: 'Owner',
|
||||
},
|
||||
],
|
||||
@@ -237,45 +397,68 @@ async function createProject() {
|
||||
is_draft: true,
|
||||
}
|
||||
|
||||
if (props.organizationId) {
|
||||
projectData.organization_id = props.organizationId
|
||||
}
|
||||
|
||||
formData.append('data', JSON.stringify(projectData))
|
||||
|
||||
try {
|
||||
await useBaseFetch('project', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Disposition': formData,
|
||||
},
|
||||
})
|
||||
let createdProjectId: string | undefined
|
||||
|
||||
modal.value.hide()
|
||||
if (projectType.value === 'server') {
|
||||
const result = await labrinth.projects_v3.createServerProject({
|
||||
base: {
|
||||
name: projectData.title,
|
||||
slug: projectData.slug,
|
||||
summary: projectData.description,
|
||||
description: '',
|
||||
requested_status: projectData.requested_status,
|
||||
organization_id: owner.value !== 'self' ? owner.value : undefined,
|
||||
},
|
||||
minecraft_server: {
|
||||
// empty component
|
||||
},
|
||||
minecraft_java_server: {
|
||||
address: '',
|
||||
},
|
||||
minecraft_bedrock_server: {
|
||||
address: '',
|
||||
},
|
||||
})
|
||||
createdProjectId = result.id
|
||||
} else {
|
||||
const result = (await useBaseFetch('project', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Disposition': formData as unknown as string,
|
||||
},
|
||||
})) as Labrinth.Projects.v3.Project
|
||||
createdProjectId = result.id
|
||||
console.log(createdProjectId)
|
||||
}
|
||||
|
||||
modal.value?.hide()
|
||||
await router.push(`/project/${slug.value}/settings`)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: formatMessage(messages.errorTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: error.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
function show(event) {
|
||||
async function show(event?: MouseEvent, options?: ShowOptions) {
|
||||
name.value = ''
|
||||
slug.value = ''
|
||||
description.value = ''
|
||||
manualSlug.value = false
|
||||
modal.value.show(event)
|
||||
owner.value = 'self'
|
||||
projectType.value = options?.type ?? 'project'
|
||||
await fetchOrganizations()
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
function updatedName() {
|
||||
if (!manualSlug.value) {
|
||||
slug.value = name.value
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
v-model="isUSCitizen"
|
||||
:items="['yes', 'no']"
|
||||
:format-label="
|
||||
(item) => (item === 'yes' ? formatMessage(messages.yes) : formatMessage(messages.no))
|
||||
(item) =>
|
||||
item === 'yes'
|
||||
? formatMessage(commonMessages.yesLabel)
|
||||
: formatMessage(commonMessages.noLabel)
|
||||
"
|
||||
:never-empty="false"
|
||||
:capitalize="true"
|
||||
@@ -162,6 +165,7 @@ import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
@@ -228,8 +232,6 @@ const messages = defineMessages({
|
||||
id: 'dashboard.creator-tax-form-modal.us-citizen.question',
|
||||
defaultMessage: 'Are you a US citizen?',
|
||||
},
|
||||
yes: { id: 'common.yes', defaultMessage: 'Yes' },
|
||||
no: { id: 'common.no', defaultMessage: 'No' },
|
||||
entityQuestion: {
|
||||
id: 'dashboard.creator-tax-form-modal.entity.question',
|
||||
defaultMessage: 'Are you a private individual or part of a foreign entity?',
|
||||
|
||||
@@ -58,24 +58,20 @@
|
||||
</div>
|
||||
<template #actions>
|
||||
<div v-if="currentStage === 'completion'" class="mt-4 flex w-full gap-3">
|
||||
<ButtonStyled class="flex-1">
|
||||
<button class="w-full text-contrast" @click="handleClose">
|
||||
{{ formatMessage(messages.closeButton) }}
|
||||
<ButtonStyled>
|
||||
<button class="w-full flex-1 text-contrast" @click="handleClose">
|
||||
{{ formatMessage(commonMessages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled class="flex-1">
|
||||
<button class="w-full text-contrast" @click="handleViewTransactions">
|
||||
<ButtonStyled>
|
||||
<button class="w-full flex-1 text-contrast" @click="handleViewTransactions">
|
||||
{{ formatMessage(messages.transactionsButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="mt-4 flex flex-col justify-end gap-2 sm:flex-row">
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!border-surface-5"
|
||||
:disabled="leftButtonConfig.disabled"
|
||||
@click="leftButtonConfig.handler"
|
||||
>
|
||||
<button :disabled="leftButtonConfig.disabled" @click="leftButtonConfig.handler">
|
||||
<component :is="leftButtonConfig.icon" />
|
||||
{{ leftButtonConfig.label }}
|
||||
</button>
|
||||
@@ -107,6 +103,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -128,12 +125,13 @@ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import {
|
||||
createWithdrawContext,
|
||||
getTaxThreshold,
|
||||
getTaxThresholdActual,
|
||||
type PaymentProvider,
|
||||
type PayoutMethod,
|
||||
provideWithdrawContext,
|
||||
TAX_THRESHOLD_ACTUAL,
|
||||
type WithdrawStage,
|
||||
} from '@/providers/creator-withdraw.ts'
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
|
||||
import CreatorTaxFormModal from './CreatorTaxFormModal.vue'
|
||||
import CompletionStage from './withdraw-stages/CompletionStage.vue'
|
||||
@@ -144,21 +142,9 @@ import MuralpayKycStage from './withdraw-stages/MuralpayKycStage.vue'
|
||||
import TaxFormStage from './withdraw-stages/TaxFormStage.vue'
|
||||
import TremendousDetailsStage from './withdraw-stages/TremendousDetailsStage.vue'
|
||||
|
||||
type FormCompletionStatus = 'unknown' | 'unrequested' | 'unsigned' | 'tin-mismatch' | 'complete'
|
||||
|
||||
interface UserBalanceResponse {
|
||||
available: number
|
||||
withdrawn_lifetime: number
|
||||
withdrawn_ytd: number
|
||||
pending: number
|
||||
dates: Record<string, number>
|
||||
requested_form_type: string | null
|
||||
form_completion_status: FormCompletionStatus | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
balance: UserBalanceResponse | null
|
||||
preloadedPaymentData?: { country: string; methods: PayoutMethod[] } | null
|
||||
balance: Labrinth.Payout.v3.PayoutBalance | null | undefined
|
||||
preloadedPaymentData?: { country: string; methods: Labrinth.Payout.v3.PayoutMethod[] } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -191,9 +177,13 @@ defineExpose({
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const generatedState = useGeneratedState()
|
||||
const taxComplianceThresholds = computed(() => generatedState.value.taxComplianceThresholds)
|
||||
|
||||
const withdrawContext = createWithdrawContext(
|
||||
props.balance,
|
||||
props.preloadedPaymentData || undefined,
|
||||
taxComplianceThresholds.value,
|
||||
)
|
||||
provideWithdrawContext(withdrawContext)
|
||||
|
||||
@@ -249,13 +239,13 @@ const needsTaxForm = computed(() => {
|
||||
const ytd = props.balance.withdrawn_ytd ?? 0
|
||||
const available = props.balance.available ?? 0
|
||||
const status = props.balance.form_completion_status
|
||||
return status !== 'complete' && ytd + available >= 600
|
||||
return status !== 'complete' && ytd + available >= getTaxThreshold(taxComplianceThresholds.value)
|
||||
})
|
||||
|
||||
const remainingLimit = computed(() => {
|
||||
if (!props.balance) return 0
|
||||
const ytd = props.balance.withdrawn_ytd ?? 0
|
||||
const raw = TAX_THRESHOLD_ACTUAL - ytd
|
||||
const raw = getTaxThresholdActual(taxComplianceThresholds.value) - ytd
|
||||
if (raw <= 0) return 0
|
||||
const cents = Math.floor(raw * 100)
|
||||
return cents / 100
|
||||
@@ -601,26 +591,10 @@ const messages = defineMessages({
|
||||
id: 'dashboard.creator-withdraw-modal.stage.method-selection',
|
||||
defaultMessage: 'Method',
|
||||
},
|
||||
tremendousDetailsStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.tremendous-details',
|
||||
defaultMessage: 'Details',
|
||||
},
|
||||
muralpayKycStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.muralpay-kyc',
|
||||
defaultMessage: 'Verification',
|
||||
},
|
||||
muralpayDetailsStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.muralpay-details',
|
||||
defaultMessage: 'Account Details',
|
||||
},
|
||||
completionStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.completion',
|
||||
defaultMessage: 'Complete',
|
||||
},
|
||||
detailsLabel: {
|
||||
id: 'dashboard.creator-withdraw-modal.details-label',
|
||||
defaultMessage: 'Details',
|
||||
},
|
||||
completeTaxForm: {
|
||||
id: 'dashboard.creator-withdraw-modal.complete-tax-form',
|
||||
defaultMessage: 'Complete tax form',
|
||||
@@ -629,14 +603,14 @@ const messages = defineMessages({
|
||||
id: 'dashboard.creator-withdraw-modal.continue-with-limit',
|
||||
defaultMessage: 'Continue with limit',
|
||||
},
|
||||
detailsLabel: {
|
||||
id: 'dashboard.creator-withdraw-modal.details-label',
|
||||
defaultMessage: 'Details',
|
||||
},
|
||||
withdrawButton: {
|
||||
id: 'dashboard.creator-withdraw-modal.withdraw-button',
|
||||
defaultMessage: 'Withdraw',
|
||||
},
|
||||
closeButton: {
|
||||
id: 'dashboard.withdraw.completion.close-button',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
transactionsButton: {
|
||||
id: 'dashboard.withdraw.completion.transactions-button',
|
||||
defaultMessage: 'Transactions',
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
<StyledInput
|
||||
ref="amountInput"
|
||||
:value="modelValue"
|
||||
:model-value="modelValue"
|
||||
type="number"
|
||||
step="0.01"
|
||||
:step="0.01"
|
||||
:min="minAmount"
|
||||
:max="safeMaxAmount"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.amountPlaceholder)"
|
||||
class="w-full rounded-[14px] bg-surface-4 py-2.5 pl-4 pr-4 text-contrast placeholder:text-secondary"
|
||||
@input="handleInput"
|
||||
wrapper-class="w-full"
|
||||
@update:model-value="handleStyledInput"
|
||||
/>
|
||||
</div>
|
||||
<Combobox
|
||||
@@ -22,8 +22,8 @@
|
||||
class="w-min"
|
||||
@update:model-value="$emit('update:selectedCurrency', $event)"
|
||||
>
|
||||
<template v-for="option in currencyOptions" :key="option.value" #[`option-${option.value}`]>
|
||||
<span class="font-semibold leading-tight">{{ option.label }}</span>
|
||||
<template #option="{ item }">
|
||||
<span class="font-semibold leading-tight">{{ item.label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<ButtonStyled>
|
||||
@@ -54,10 +54,11 @@ import {
|
||||
Combobox,
|
||||
commonMessages,
|
||||
formFieldPlaceholders,
|
||||
StyledInput,
|
||||
useFormatMoney,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -81,7 +82,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const amountInput = ref<HTMLInputElement | null>(null)
|
||||
const formatMoney = useFormatMoney()
|
||||
const amountInput = ref<InstanceType<typeof StyledInput> | null>(null)
|
||||
|
||||
const safeMaxAmount = computed(() => {
|
||||
return Math.max(0, props.maxAmount)
|
||||
@@ -101,26 +103,19 @@ const isAboveMaximum = computed(() => {
|
||||
return props.modelValue !== undefined && props.modelValue > safeMaxAmount.value
|
||||
})
|
||||
|
||||
async function setMaxAmount() {
|
||||
function setMaxAmount() {
|
||||
const maxValue = safeMaxAmount.value
|
||||
emit('update:modelValue', maxValue)
|
||||
|
||||
await nextTick()
|
||||
if (amountInput.value) {
|
||||
amountInput.value.value = maxValue.toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.value
|
||||
function handleStyledInput(val: string | number) {
|
||||
const value = String(val)
|
||||
|
||||
if (value && value.includes('.')) {
|
||||
const parts = value.split('.')
|
||||
if (parts[1] && parts[1].length > 2) {
|
||||
const rounded = Math.floor(parseFloat(value) * 100) / 100
|
||||
emit('update:modelValue', rounded)
|
||||
input.value = rounded.toString()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -131,14 +126,10 @@ function handleInput(event: Event) {
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newAmount) => {
|
||||
(newAmount) => {
|
||||
if (newAmount !== undefined && newAmount !== null) {
|
||||
if (newAmount > safeMaxAmount.value) {
|
||||
emit('update:modelValue', safeMaxAmount.value)
|
||||
await nextTick()
|
||||
if (amountInput.value) {
|
||||
amountInput.value.value = safeMaxAmount.value.toFixed(2)
|
||||
}
|
||||
} else if (newAmount < 0) {
|
||||
emit('update:modelValue', 0)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
>{{ formatTransactionStatus(transaction.status) }} <BulletDivider
|
||||
/></span>
|
||||
</template>
|
||||
{{ dayjs(transaction.created).format('MMM DD YYYY') }}
|
||||
{{ formatDate(transaction.created) }}
|
||||
<template v-if="transaction.type === 'withdrawal' && transaction.fee">
|
||||
<BulletDivider /> Fee {{ formatMoney(transaction.fee) }}
|
||||
</template>
|
||||
@@ -48,7 +48,12 @@
|
||||
>{{ isIncome ? '' : '-' }}{{ formatMoney(transaction.amount) }}</span
|
||||
>
|
||||
<template v-if="transaction.type === 'withdrawal' && transaction.status === 'in-transit'">
|
||||
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0"
|
||||
:triggers="['hover', 'focus']"
|
||||
no-auto-focus
|
||||
>
|
||||
<span class="my-auto align-middle"
|
||||
><ButtonStyled circular type="outlined" size="small">
|
||||
<button class="align-middle" @click="cancelPayout">
|
||||
@@ -66,6 +71,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
@@ -79,40 +85,17 @@ import {
|
||||
ButtonStyled,
|
||||
getCurrencyIcon,
|
||||
injectNotificationManager,
|
||||
useFormatDateTime,
|
||||
useFormatMoney,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString, formatMoney } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { findRail } from '~/utils/muralpay-rails'
|
||||
|
||||
type PayoutStatus = 'in-transit' | 'cancelling' | 'cancelled' | 'success' | 'failed'
|
||||
type PayoutMethodType = 'paypal' | 'venmo' | 'tremendous' | 'muralpay'
|
||||
type PayoutSource = 'creator_rewards' | 'affilites'
|
||||
|
||||
type WithdrawalTransaction = {
|
||||
type: 'withdrawal'
|
||||
id: string
|
||||
status: PayoutStatus
|
||||
created: string
|
||||
amount: number
|
||||
fee?: number | null
|
||||
method_type?: PayoutMethodType | null
|
||||
method?: string
|
||||
method_id?: string
|
||||
method_address?: string | null
|
||||
}
|
||||
|
||||
type PayoutAvailableTransaction = {
|
||||
type: 'payout_available'
|
||||
created: string
|
||||
payout_source: PayoutSource
|
||||
amount: number
|
||||
}
|
||||
|
||||
type Transaction = WithdrawalTransaction | PayoutAvailableTransaction
|
||||
type Transaction = Labrinth.Payout.v3.TransactionItem
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: Transaction
|
||||
@@ -188,6 +171,8 @@ function formatTransactionStatus(status: string): string {
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatMoney = useFormatMoney()
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'medium' })
|
||||
|
||||
function formatMethodName(method: string | undefined, method_id: string | undefined): string {
|
||||
if (!method) return 'Unknown'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user