Merge tag 'v0.14.6' into beta

v0.14.6
This commit is contained in:
2026-06-17 02:14:47 +03:00
2497 changed files with 357074 additions and 111947 deletions
+7 -24
View File
@@ -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>
+80 -550
View File
@@ -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);
}
}
+8 -25
View File
@@ -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>
@@ -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%)',
]
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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,
}
}
@@ -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>
@@ -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>
@@ -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)
}
@@ -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,
}
}
@@ -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,
}
}
@@ -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
}
@@ -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>
@@ -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)
}
@@ -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],
)
)
}
@@ -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_')
}
@@ -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()
}
@@ -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(', ')
}
@@ -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)
}
@@ -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] ?? '') : ''
}
}
@@ -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,
}
}
@@ -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
}
@@ -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>
@@ -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,
}
}
@@ -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,
}
}
@@ -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>
@@ -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,
}
}
+1 -1
View File
@@ -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>
+47 -61
View File
@@ -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>
+7 -3
View File
@@ -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"
+3 -2
View File
@@ -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
-303
View File
@@ -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">&nbsp;</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">&nbsp;</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">&nbsp;</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">&nbsp;</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',
})
@@ -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&#10;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&#10;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
@@ -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
}>()
@@ -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),
@@ -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']
@@ -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') {
@@ -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({
@@ -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)
}
@@ -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