Compare commits

...
Author SHA1 Message Date
aecsocket 58e2b50255 improve oauth token gen code 2026-05-30 18:50:06 +01:00
aecsocket 25d0863b1a Prepare 2026-05-30 17:51:27 +01:00
aecsocket bb151bc94a Revert openapi 2026-05-30 16:37:24 +01:00
aecsocket 8df1e28172 fix 2026-05-30 16:35:01 +01:00
aecsocket 2fa38d8ee0 add num donators 2026-05-30 16:35:01 +01:00
aecsocket 9c9c63bd5e redis caching 2026-05-30 16:35:01 +01:00
aecsocket e7b363d50a fix 2026-05-30 16:35:01 +01:00
aecsocket 65b45077b7 wip: tiltify 2026-05-30 16:35:01 +01:00
aecsocket 24d732126d idempotency 2026-05-30 16:35:01 +01:00
aecsocket 64d4a207cf Pride 26 campaign 2026-05-30 16:35:01 +01:00
aecsocket 5192c25420 improve donation resolution 2026-05-30 16:35:01 +01:00
aecsocket d58edddd0b fix up code 2026-05-30 16:35:01 +01:00
aecsocket 634e1e0129 setup webhook and link to user 2026-05-30 16:35:00 +01:00
aecsocket 5806e00111 wip: pride 2026 webhooks and stuff 2026-05-30 16:35:00 +01:00
Prospector 8c95f0bb81 changelog 2026-05-29 16:10:27 -07:00
Truman GaoandGitHub 627ab6734b fix: revenue not showing for all time query (#6246) 2026-05-29 15:47:17 -07:00
Prospector 36e1cbbf3e changelog 2026-05-29 15:06:42 -07:00
Truman GaoandGitHub 93fe87e57f feat: handle errors in analytics (#6245)
* fix: uri too long error by temporarily batching in frontend

* feat: fix empty state with error
2026-05-29 15:06:04 -07:00
Truman GaoandGitHub 02363c27a2 fix: download threshold (#6242)
* fix: download threshold

* fix: download threshold for projects select

* refactor: pnpm prepr

* feat: handle facets not adding count

* feat: remove getting facets download count field entirely

* feat: update facets to match new backend shape
2026-05-29 14:41:43 -07:00
aecsocketandGitHub 67e1743d6c Flatten facets response format, remove detailed route (#6244)
* Flatten facets response format

* delete test file
2026-05-29 23:22:08 +02:00
Prospector cef20abceb remove old articles 2026-05-29 13:50:31 -07:00
aecsocketandGitHub 62cedab6dd Analytics faceting fixes (#6243) 2026-05-29 22:46:51 +02:00
Prospector ed5a74a9d3 changelog 2026-05-29 13:03:02 -07:00
Prospector e2318ee776 prepr 2026-05-29 12:57:51 -07:00
Prospector a29c1c61c4 prepr 2026-05-29 12:56:37 -07:00
206813b74c feat: analytics 2.0 blogpost (#6235)
* feat: analytics 2.0 blogpost

* update blog post

* performance: compress mp4s

* date

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-05-29 12:47:44 -07:00
aecsocketandGitHub 99611d22c7 Filter out invalid loaders in analytics, fix query bug (#6241)
* should fix bucketing query bug

* Filter out invalid loaders, fix query bug

* fixes
2026-05-29 12:46:59 -07:00
11b2b6e6c0 feat: improve analytics dashboard (#5897)
* feat: implement cancel/apply for custom timeframe range picker

* feat: implement dot for showing todays date

* feat: add max date to be today and show todays date

* feat: if ratio mode, dont show total

* feat: implement show more batching excess lines into "Other" bucket

* refactor: pnpm prepr

* feat: add pick and plop for date range start/end dates

* feat: implement reset query button

* feat: clear button to clear breakdown

* feat: more aggressively trim allowed minimum group by option

* fix: dont show project status filter when from project settings/analytics

* fix: clear selected X above number when appropriate

* feat: graph style updates and dont show year in x axis unless more than 2 year timeframe

* fix: loading state to include legend in blur

* feat: add project icon to project select

* feat: filter out draft projects from analytics

* feat: implement multiselect sections headers, project select org sections, and project options icons

* feat: implement click and drag to select date range

* feat: implement windows history for query builder

* revert: no longer switch breakdown/filter option if same category

* feat: implement showing project for project version breakdown/filter when there are multiple projects

* feat: implement modrinth sided events

* fix: border radius

* feat: implement analytics range highlight

* fix: loading state showing empty state text

* refactor: pnpm prepr

* feat: improve dropdown filter bar and multiselect performance

* fix: multiselect keyboard use

* fix: graph overflow issues

* fix: loading state text on table

* feat: implement tooltip scroll

* fix: adjust charts event tooltip

* feat: shorten time to not repeat am/pm

* feat: implement query params for graph component settings

* fix: qa

* feat: add reset timeframe button

* fix: legend colors moving between metric by determining color based on only downloads metric index

* feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days

* fix: change to > 1 day

* fix: custom timeframe picker

* feat: implement big performance improvement for table

* feat: implement hover on legend to highlight graph

* fix: defer commit in query builder/filter and style fixes

* feat: more performance optimization to analytics dashboard state, chart, and table

* feat: add tooltip for other item

* feat: improve custom time frame range select

* feat: implement analytics events admin page

* fix: switch column order

* pnpm prepr

* feat: implement mock analytics events

* feat: improve analytics events admin page

* feat: focus title input on analytics create event modal

* fix: remove labels annoying

* feat: hook up analytics events backend

* fix: type error

* feat: reduce combobox padding

* feat: reduce padding on multiselect

* feat: add overlay scrollbar for combobox

* feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar

* feat: MORE PADDING fixes

* feat: use user_agent for download source

* Revert "feat: use user_agent for download source"

This reverts commit d6dc8a99f1.

* fix: query filter project version lag and borked virtualization

* feat: rename breakdown "none" to "project"

* feat: implement right side checkmark for multiselect

* feat: keep crossed out legend items still shown in tooltip but also crossed out

* fix: focus styles

* fix: focus styles pt2

* feat: implement filter by top 8

* fix: preview is incorrect when selecting same date in range date picker

* feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip

* feat (playtest): table component controls what graph shows

* feat: change download source to use user_agent

* feat: fix click to cross out in legend

* feat: add hover legend item to highlight line in tooltip

* fix: export csv to always be dropdown

* feat: implement breakdown = none

* performance: frontend memory reduction

* performance: reduce memory usage from project versions query by keeping only whats necessary

* fix: table checked items not in graph if 0

* feat: add shift click to select a range in table

* performance: add caching for metric types so switching between them is snappy

* performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy

* feat: add analytics table search

* refactor: pnpm prepr

* fix: query filter options not coming in from analytics fetch

* feat: remove breakdown = none when there are multiple projects

* feat: improve table sorting

* feat: sort projects in project dropdown

* fix: getting project name for project versions

* fix: add loading state for filter and parallel fetch

* performance: use precomputed map for project version options to remove first hover lag

* feat: dropdown filter always open on one side and improve styles

* fix: custom time range picker being weird

* refactor: pnpm prepr

* fix: add back in batch with 300ms interval for projects to prevent backend rate limiting

* performance: only do queries to populate graph first before other analytics queries

* fix: QA polish issues around style and copy

* feat: dont show select all when its just one item in section

* fix: bugs with ratio mode and hiding chart lines

* fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect

* fix: small styles

* fix: polish admin analytic events

* fix: keep scroll position with selection action row appearing when selecting one

* feat: add subheading in graph for showing N items from table

* feat: add unmonetized explaination tooltip

* performance: implement limit on how many lines can be shown in graph

* feat: mobile pass

* refactor: pnpm prepr

* add clear button

* feat: add time in analytics event and normalize date/time so its correct to timezones

* fix: padding

* feat: implement show prev period toggle

* feat: extract TimeFramePicker to packages/ui

* fix: adjust style

* feat: keep table selected persisted in query parameter

* fix: style on prev item value in legend

* fix: when breakdown switches, reset selected series

* fix: tooltip styles

* feat: change project selection to reset to show top 8 only if reconciled down to 0 items

* feat: implement show top 8 button in graph subheading

* fix: rename download type to download reason

* fix: formatting label for table

* feat: persist table sort by and sort direction

* fix: show top 8 button in graph not defaulting to top 8 for other metrics

* feat: implement prev period analytics fetch into the same current period fetch by shifting start date

* refactor: pnpm prepr

* fix: remove number if its just top 1

* fix: brief select items empty state when switch breakdown

* feat: implement format table playtime column

* feat: update export csv filename

* feat: change playtime column to display in hours

* refactor: pnpm prepr

* fix: still download type in filter

* feat: update analytics tooltip

* fix: wrong all projects icon

* feat: force legend order and graph colour for monetization

* refactor: pnpm prepr

* fix: multiselect and combobox sizes

* fix: chart icon add hover delay

* feat: (to playtest) implement multiple breakdowns

* fix: couple UX things for multiple breakdown

* fix: cannot unpin on page click

* fix: multiple breakdown legend and tooltip labels

* feat: add right side checkmark for dropdown filtr bar

* feat: enabling prev period will cross out prev for current ones already crossed out

* feat-mobile: remove drag to select time frame in graph

* feat-mobile: dropdown filter to replace dropdown for submenus on small screen

* feat-mobile: time frame picker to use different start and end date pickers for mobile

* fix-mobile: fix multiselect scroll on mobile

* feat: consolidate is mobile ref into context

* fix-mobile: combobox and multiselect scroll bug when mobile search bar open, fix timeframe picker mobile pick date, and dropdown filter bar click outside to close

* fix-mobile: smaller metric card font

* fix: dropdown filter bar scroll while search

* feat: implement project side events

* feat: implement better mobile view design for query builder

* feat: handle events overflow

* small: add select none

* feat: remove clear project and breakdown

* fix: event icon hover color

* feat: default hide project events if there are multiple projects, and default show if only 1 project

* feat: implement analytics performance updates, including facets, and v3 user projects

* feat: grey out dimmed lined on legend item hover

* feat-mobile: style fixes

* add close on select prop

* feat: add close on select for time frame picker mobile

* feat: date picker default read only

* refactor: pnpm prepr

* feat: default to projects breakdown instead of no breakdown with multiple projects

* fix-mobile: improve graph touch interactions

* small: 2 sig figs on playtime

* feat: deduplicate version uploads that have same version number and are uploaded on same day

* fix: analytics events grouping causing overflow

* feat: improve performance on analytics events grouping

* fix: tooltip expanding page width briefly

* fix: prevent double tap to zoom on inputs

* feat: add click to show chart event for mobile

* fix: toggle not having touch manipulation

* fix: chart tooltip scroll in mobile

* fix: remove project breakdownoption as it is default breakdown when none are selected

* fix: dropdown filter bar briefly empty when switching pages in mobile

* feat: keep tooltip open after drag in mobile

* fix: using plural instead of single for project breakdown

* fix: date picker scrolling page after picking date in mobile by suppressing focus

* fix: callback to Organization instead of org id

* feat: improve chart tooltip date range label formatter to be much more consistent

* feat: tap to toggle event tooltip

* fix: add user select none on graph and fix zoom into download threshold input

* fix: frontend still filtering after backend already filters

* feat: fix emptys state height content shift

* fix: qa issues

* fix: a number of qa issues

- Hide project events based on visible project legend/table selection
- Filter project status events by end status and add explicit copy for approved, private, and unlisted
- Style Modrinth analytics events with blue icon, marker, guide, and range borders
- Add scroll fade shadows to analytics chart and event tooltips
- Show previous-period date range in the chart tooltip
- Make project breakdown conditional on multiple selected projects and allow no breakdown when none are selected
- Add breakdown selection actions and fix “Group by day” copy

* feat: implement graph controls dropdown

* fix date picker typing into time input

* fix: styles in events table

* small: style

* feat: implement using new backend facets route

* feat: implement user get all projects

* performance: deter non-critical fetches to after analytics is in

* fix: refreshing causes multiple projects to do breakdown=none

* performance: cache project version options to fix lag on open sub menu

* refactor: remove chart event height being controlled by parent

* feat: update controls dropdown to have fainter border

* fix: loading bar not fading away

* fix: cannot click in graph

* feat: dont conditionally show multiselect selection actions

* fix: z-index and padding issues

* fix: project events incorrectly toggling on for first page load

* feat: remove show more and show less in legend, always show all

* fix: playtime y axis labels

* feat: improve y axis formatting for playtime and others

* feat: use tabs for game version select, and remove prev period when change breakdown or project selection

* refactor: pnpm prepr

* feat: change hidden legend items to not contribute to ratio percentages

* feat: event icon consume scroll for tooltip panel

* feat: remove gap inside chart tooltip

* feat: add gap for date picker 2 calendar view

* feat: improve analytics events grouping logic for modrinth events to be close to target

* pnpm prepr

* fix: cant click in gap in toggle

* fix: bugs around selected series from table not persisting with timeframe or filter changes

* refactor: kabab case

* refactor: split up large analytics chart and table component files into smaller components and ts modules

* fix: legend is stale after resetting query

* refactor: split up giant analytics provider with utils

* i18n pass

* revert: format number composable change

* fix: playtime was choosing y axis ticks in seconds instead of hours

* refactor: rename folder that with components to match main component name

* refactor: same rename for analytics table for consistency

* refactor: name main components to index.vue and keep folder name as component name

* refactor: pnpm prepr

* refactor: rename types

* refactor: move query builder types into types file and move components into components/analytics-dashboard

* refactor: colocate query builder url with analytics-dashboard component

* refactor: pnpm prepr:frontend

* fix: download threshold not width fit

* fix: no option to see release/all game versions in selected filter dropdown

* fix: game version dropdown width

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-05-29 19:39:55 +00:00
Calum H.andGitHub f49951084e fix: prev message invalid treat as changed (#6236) 2026-05-29 16:19:58 +00:00
Calum H.andGitHub 047b8c3bf7 fix: i18n string problems (#6131)
* fix: apply non-json i18n fixes

* fix: pruning

* fix: prepr

* fix: run.mjs

* fix: lint
2026-05-29 15:55:39 +00:00
Calum H.andGitHub 5c1ffd9ff2 fix: resubmit i18n bug (#6218) 2026-05-29 12:17:08 +00:00
Calum H.andGitHub 670b3c17b6 feat: use split up backup cancel endpoints (#6217) 2026-05-29 12:16:57 +00:00
Prospector ec7c538888 changelog 2026-05-28 18:45:43 -07:00
Prospector b88341e3a8 fix: clean up user page, add verification on modrinth user, fix collection sidebar width 2026-05-28 18:44:25 -07:00
Truman GaoandGitHub 2048d8008a fix: sign in buttons showing in mobile navbar when hidden (#6232) 2026-05-29 01:18:20 +00:00
aecsocketandGitHub 527f2f800b Analytics faceting return count, improve performance (#6229) 2026-05-28 17:58:19 +00:00
SychicandGitHub c7d3229fd3 fix(frontend): incorrect values prop name (#6224) 2026-05-28 16:54:51 +00:00
aecsocketandGitHub ec49a3b051 Move all-projects route to specific user scope (#6220)
* Move all-projects route to specific user scope

* ci

* ci flaky test

* fmt
2026-05-28 14:41:57 +00:00
Calum H.andGitHub cf1d948030 fix: desync on flush (#6223) 2026-05-27 23:22:09 +00:00
fe8fa4b6f7 feat: changelog app 0.14.0 (#6222)
* feat: changelog app 0.14.0

* fix: changelog

* Update packages/blog/changelog.ts

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>

* Update packages/blog/changelog.ts

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>

---------

Signed-off-by: Calum H. <hendersoncal117@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
2026-05-27 22:47:28 +00:00
Modrinth BotandGitHub 2c62cf1d12 New translations from Crowdin (main) (#6203) 2026-05-27 22:28:26 +00:00
Calum H.andGitHub 84b91f32f8 fix: skins QA problems + flow change (#6216)
* fix: skins backend bugs + apply flow

* fix: caching structure

* feat: collapse already duplicated skins + fix moj api spam

* fix: doc

* fix: flatten migrations

* feat: remove default cape/cape override concept

* fix: fmt + lint

* feat: remove SelectCapeModal for inline cape list

* feat: qa

* feat: virtualisation of skins sections + fix texture/model cache

* fix: lint

* fix: virt bugs + renderer fixes

* fix: qa bugs

* fix: doc

* fix: re-add click impulse anim from prototypes + re-add interact anim length cap

* fix: regressions

* devex: split up SkinPreviewrenderer

* fix: lint

* fix: introduce dynamic mode in virtual-scroll.ts

* feat: qa

* fix: nametag bug + remove minecon skin pack suffix

* feat: pain (literally)

* feat: user agent on moj reqs

* feat: impl per account flush queue for operations

* fix: breadcrumb

* chore: i18n pass

* fix: lint + prep + check

* fix: misalignments
2026-05-27 22:22:24 +00:00
aecsocketandGitHub 64edf2ddeb Improve analytics performance, analytics faceting (#6180)
* fix uri too long

* all projects route for user

* analytics facet fetching

* cache download source regexes

* filtering

* prepare

* Split up analytics metrics into separate modules

* prepare

* fix ci
2026-05-27 00:01:13 +00:00
aecsocketandGitHub 2f248027d6 Also run Daedalus manifest uploads when there's a new game version (#6213) 2026-05-26 21:55:54 +00:00
ThatGravyBoatandGitHub f96520638b feat: Add user-images.githubusercontent.com in allowedHostnames (#6210)
feat: Allow user-images.githubusercontent.com in descriptions
2026-05-26 14:52:35 +00:00
383 changed files with 34933 additions and 12655 deletions
+14
View File
@@ -56,6 +56,17 @@ jobs:
ref: ${{ github.ref }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Install script dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Configure Git author
id: git-author
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
@@ -95,6 +106,9 @@ jobs:
shell: bash
run: sudo chown -R $USER:$USER .
- name: Prune stale ICU translations
run: pnpm scripts i18n-icu-contract prune-local
- name: Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
+22 -1
View File
@@ -4,11 +4,14 @@ on:
push:
branches: ['main']
paths:
- '.github/workflows/i18n.push.yml'
- '.github/workflows/i18n-push.yml'
- 'apps/*/src/locales/en-US/**'
- 'apps/*/locales/en-US/**'
- 'packages/*/src/locales/en-US/**'
- 'packages/*/locales/en-US/**'
- 'scripts/i18n-icu-contract.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- 'crowdin.yml'
workflow_dispatch:
@@ -56,6 +59,18 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.ref }}
fetch-depth: 2
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Install script dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Query branch name
id: branch-name
@@ -79,3 +94,9 @@ jobs:
env:
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Clear stale ICU translations in Crowdin
run: pnpm scripts i18n-icu-contract clear-crowdin-changed --base-ref HEAD^ --crowdin-branch "[${{ github.repository_owner }}.${{ github.event.repository.name }}] ${{ steps.branch-name.outputs.safe_branch_name }}"
env:
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+2 -1
View File
@@ -204,4 +204,5 @@ jobs:
- name: Verify intl:extract has been run
run: |
pnpm turbo run intl:extract --force
git diff --exit-code --color */*/src/locales/en-US/index.json
pnpm scripts i18n-icu-contract prune-local --check
git diff --exit-code --color */*/src/locales/*/index.json
+1
View File
@@ -10,6 +10,7 @@
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
"test": "vue-tsc --noEmit"
},
"dependencies": {
+3 -1
View File
@@ -330,6 +330,7 @@ async function setupApp() {
locale,
telemetry,
collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
onboarded,
default_page,
@@ -360,6 +361,7 @@ async function setupApp() {
themeStore.setThemeState(theme)
themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
themeStore.toggleSidebar = toggle_sidebar
themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags
@@ -1227,7 +1229,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
<ChangeSkinIcon />
</NavButton>
<NavButton
@@ -150,7 +150,10 @@ async function refreshValues() {
if (equippedSkin.value) {
try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
headUrlCache.value = new Map(headUrlCache.value).set(
equippedSkin.value.texture_key,
headUrl,
)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
@@ -160,12 +163,24 @@ async function refreshValues() {
}
}
async function setEquippedSkin(skin: Skin) {
equippedSkin.value = skin
try {
const headUrl = await getPlayerHeadUrl(skin)
headUrlCache.value = new Map(headUrlCache.value).set(skin.texture_key, headUrl)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
}
function setLoginDisabled(value: boolean) {
loginDisabled.value = value
}
defineExpose({
refreshValues,
setEquippedSkin,
setLoginDisabled,
loginDisabled,
})
@@ -160,7 +160,16 @@ watch(
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
</div>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
@@ -1,149 +1,242 @@
<template>
<UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState">
<NewModal ref="modal" :on-hide="handleModalHide">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
</span>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
</div>
<div class="h-[25rem] w-[16rem] min-w-[16rem] flex-shrink-0 md:self-center">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
framing="modal"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
</div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<section v-if="mode === 'edit' && canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
<ButtonStyled>
<button @click="openUploadSkinModal"><UploadIcon /> Replace texture</button>
<button class="!shadow-none" @click="openTextureFileBrowser">
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
</button>
</ButtonStyled>
<input
ref="textureFileInput"
type="file"
accept="image/png"
class="hidden"
@change="onTextureFileInputChange"
/>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Arm style</h2>
<section v-if="canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">
{{ formatMessage(messages.armStyleSection) }}
</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }">
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
{{
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
}}
</template>
</RadioButtons>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2">
<CapeButton
v-if="defaultCape"
:id="defaultCape.id"
:texture="defaultCape.texture"
:name="undefined"
:selected="!selectedCape"
faded
@select="selectCape(undefined)"
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
<div class="relative w-fit max-w-full">
<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"
>
<span>Use default cape</span>
</CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span>
</CapeLikeTextButton>
<div
v-if="showCapeTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<CapeButton
v-for="cape in visibleCapeList"
:id="cape.id"
:key="cape.id"
:texture="cape.texture"
:name="cape.name || 'Cape'"
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
<CapeLikeTextButton
v-if="(capes?.length ?? 0) > 2"
tooltip="View more capes"
@mouseup="openSelectCapeModal"
<div
ref="capeListRef"
class="grid grid-cols-[repeat(4,max-content)] auto-rows-max gap-2 overflow-y-auto pr-1"
:style="{ maxHeight: capeListMaxHeight }"
@scroll="checkCapeScrollState"
>
<template #icon><ChevronRightIcon /></template>
<span>More</span>
</CapeLikeTextButton>
<CapeLikeTextButton
:tooltip="formatMessage(messages.noCapeTooltip)"
:highlighted="!selectedCape"
@click="selectCape(undefined)"
>
<template #icon><XIcon /></template>
<span>{{ formatMessage(messages.noneCapeOption) }}</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:texture="cape.texture"
:name="cape.name || formatMessage(messages.capeFallbackName)"
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
</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="showCapeBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</section>
</div>
</div>
<div class="flex gap-2 mt-12">
<ButtonStyled color="brand">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button :disabled="isSaving" @click="hide"><XIcon />Cancel</button>
</ButtonStyled>
</div>
</ModalWrapper>
<SelectCapeModal
ref="selectCapeModal"
:capes="capes || []"
@select="handleCapeSelected"
@cancel="handleCapeCancel"
/>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button :disabled="isSaving" @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
CheckIcon,
ChevronRightIcon,
SaveIcon,
SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CapeButton,
CapeLikeTextButton,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
RadioButtons,
SkinPreviewRenderer,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import {
add_and_equip_custom_skin,
type Cape,
determineModelType,
equip_skin,
get_normalized_skin_texture,
remove_custom_skin,
normalize_skin_texture,
save_custom_skin,
type Skin,
type SkinModel,
type SkinTextureUrl,
unequip_skin,
} from '@/helpers/skins.ts'
const CAPE_LIST_MAX_HEIGHT = 334
const messages = defineMessages({
editSkinTitle: {
id: 'app.skins.modal.edit-title',
defaultMessage: 'Editing skin',
},
addSkinTitle: {
id: 'app.skins.modal.add-title',
defaultMessage: 'Adding a skin',
},
textureSection: {
id: 'app.skins.modal.texture-section',
defaultMessage: 'Texture',
},
replaceTextureButton: {
id: 'app.skins.modal.replace-texture-button',
defaultMessage: 'Replace texture',
},
armStyleSection: {
id: 'app.skins.modal.arm-style-section',
defaultMessage: 'Arm style',
},
wideArmStyle: {
id: 'app.skins.modal.arm-style-wide',
defaultMessage: 'Wide',
},
slimArmStyle: {
id: 'app.skins.modal.arm-style-slim',
defaultMessage: 'Slim',
},
capeSection: {
id: 'app.skins.modal.cape-section',
defaultMessage: 'Cape',
},
noCapeTooltip: {
id: 'app.skins.modal.no-cape-tooltip',
defaultMessage: 'No cape',
},
noneCapeOption: {
id: 'app.skins.modal.none-cape-option',
defaultMessage: 'None',
},
capeFallbackName: {
id: 'app.skins.modal.cape-fallback-name',
defaultMessage: 'Cape',
},
savingTooltip: {
id: 'app.skins.modal.saving-tooltip',
defaultMessage: 'Saving...',
},
uploadSkinFirstTooltip: {
id: 'app.skins.modal.upload-skin-first-tooltip',
defaultMessage: 'Upload a skin first!',
},
makeEditFirstTooltip: {
id: 'app.skins.modal.make-edit-first-tooltip',
defaultMessage: 'Make an edit to the skin first!',
},
addSkinButton: {
id: 'app.skins.modal.add-skin-button',
defaultMessage: 'Add skin',
},
saveSkinButton: {
id: 'app.skins.modal.save-skin-button',
defaultMessage: 'Save skin',
},
})
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = useTemplateRef('modal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
const capeListRef = ref<HTMLElement | null>(null)
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
const mode = ref<'new' | 'edit'>('new')
const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false)
const isSaving = ref(false)
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
@@ -151,10 +244,49 @@ const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
const props = defineProps<{ capes?: Cape[] }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const visibleCapeList = ref<Cape[]>([])
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
const {
showTopFade: showCapeTopFade,
showBottomFade: showCapeBottomFade,
checkScrollState: checkCapeScrollState,
forceCheck: forceCapeScrollCheck,
} = useScrollIndicator(capeListRef)
let capeListLayoutFrame: number | null = null
function updateCapeListLayout() {
const capeList = capeListRef.value
const modalContent = capeList?.closest('[data-modal-content]') as HTMLElement | null
if (!capeList || !modalContent) {
capeListMaxHeight.value = `${CAPE_LIST_MAX_HEIGHT}px`
forceCapeScrollCheck()
return
}
const availableHeight =
modalContent.getBoundingClientRect().bottom - capeList.getBoundingClientRect().top
capeListMaxHeight.value = `${Math.min(
CAPE_LIST_MAX_HEIGHT,
Math.max(0, Math.floor(availableHeight)),
)}px`
nextTick(() => forceCapeScrollCheck())
}
function refreshCapeListLayout() {
if (capeListLayoutFrame !== null) {
cancelAnimationFrame(capeListLayoutFrame)
}
capeListLayoutFrame = requestAnimationFrame(() => {
capeListLayoutFrame = null
updateCapeListLayout()
})
}
const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => {
@@ -164,32 +296,6 @@ const sortedCapes = computed(() => {
})
})
function initVisibleCapeList() {
if (!props.capes || props.capes.length === 0) {
visibleCapeList.value = []
return
}
if (visibleCapeList.value.length === 0) {
if (selectedCape.value) {
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
} else {
visibleCapeList.value = getSortedCapes(2)
}
}
}
function getSortedCapes(count: number): Cape[] {
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
return sortedCapes.value.slice(0, count)
}
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
return sortedCapes.value.find((cape) => cape.id !== excludeId)
}
async function loadPreviewSkin() {
if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value.normalized
@@ -221,9 +327,13 @@ const disableSave = computed(
)
const saveTooltip = computed(() => {
if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
if (isSaving.value) return formatMessage(messages.savingTooltip)
if (mode.value === 'new' && !uploadedTextureUrl.value) {
return formatMessage(messages.uploadSkinFirstTooltip)
}
if (mode.value === 'edit' && !hasEdits.value) {
return formatMessage(messages.makeEditFirstTooltip)
}
return undefined
})
@@ -234,11 +344,13 @@ function resetState() {
previewSkin.value = ''
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
shouldRestoreModal.value = false
isSaving.value = false
}
function handleModalHide() {
setTimeout(() => resetState(), 250)
}
async function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null
@@ -249,12 +361,11 @@ async function show(e: MouseEvent, skin?: Skin) {
variant.value = 'CLASSIC'
selectedCape.value = undefined
}
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
nextTick(() => refreshCapeListLayout())
}
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
@@ -263,98 +374,54 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl.original)
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
nextTick(() => refreshCapeListLayout())
}
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
async function setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
nextTick(() => refreshCapeListLayout())
}
function hide() {
modal.value?.hide()
setTimeout(() => resetState(), 250)
}
function selectCape(cape: Cape | undefined) {
if (cape && selectedCape.value?.id !== cape.id) {
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
if (!isInVisibleList && visibleCapeList.value.length > 0) {
visibleCapeList.value.splice(0, 1, cape)
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
const otherCape = getSortedCapeExcluding(cape.id)
if (otherCape) {
visibleCapeList.value.splice(1, 1, otherCape)
}
}
}
}
selectedCape.value = cape
}
function handleCapeSelected(cape: Cape | undefined) {
selectCape(cape)
function openTextureFileBrowser() {
textureFileInput.value?.click()
}
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
async function onTextureFileInputChange(e: Event) {
const files = (e.target as HTMLInputElement).files
const file = files?.[0]
if (!file) {
return
}
}
function handleCapeCancel() {
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function openSelectCapeModal(e: MouseEvent) {
if (!selectCapeModal.value) return
shouldRestoreModal.value = true
modal.value?.hide()
setTimeout(() => {
selectCapeModal.value?.show(
e,
currentSkin.value?.texture_key,
selectedCape.value,
previewSkin.value,
variant.value,
)
}, 0)
}
function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true
modal.value?.hide()
emit('open-upload-modal', e)
}
function restoreModal() {
if (shouldRestoreModal.value) {
setTimeout(() => {
const fakeEvent = new MouseEvent('click')
modal.value?.show(fakeEvent)
shouldRestoreModal.value = false
}, 500)
try {
const originalSkinTexUrl = `data:image/png;base64,${arrayBufferToBase64(
await file.arrayBuffer(),
)}`
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
await setUploadedTexture({
original: originalSkinTexUrl,
normalized: `data:image/png;base64,${arrayBufferToBase64(skinTextureNormalized)}`,
})
} catch (error) {
handleError(error)
} finally {
if (textureFileInput.value) {
textureFileInput.value.value = ''
}
}
}
@@ -370,17 +437,32 @@ async function save() {
textureUrl = currentSkin.value!.texture
}
await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved')
const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved', {
applied: true,
skin: addedSkin,
})
} else {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!)
emit('saved')
const updatedSkin = await save_custom_skin(
currentSkin.value!,
bytes,
variant.value,
selectedCape.value,
!!uploadedTextureUrl.value && textureUrl !== currentSkin.value?.texture,
)
if (currentSkin.value?.is_equipped) {
await equip_skin(updatedSkin)
}
emit('saved', {
applied: !!currentSkin.value?.is_equipped,
skin: updatedSkin,
previousSkin: currentSkin.value!,
})
}
hide()
@@ -393,28 +475,53 @@ async function save() {
watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin()
refreshCapeListLayout()
})
watch(
() => props.capes,
() => {
initVisibleCapeList()
nextTick(() => refreshCapeListLayout())
},
{ immediate: true },
)
watch(
capeListRef,
(capeList, _, onCleanup) => {
if (!capeList) return
const modalContent = capeList.closest('[data-modal-content]')
const resizeObserver = new ResizeObserver(() => refreshCapeListLayout())
if (modalContent instanceof HTMLElement) {
resizeObserver.observe(modalContent)
}
window.addEventListener('resize', refreshCapeListLayout, { passive: true })
refreshCapeListLayout()
onCleanup(() => {
resizeObserver.disconnect()
window.removeEventListener('resize', refreshCapeListLayout)
if (capeListLayoutFrame !== null) {
cancelAnimationFrame(capeListLayoutFrame)
capeListLayoutFrame = null
}
})
},
{ flush: 'post' },
)
const emit = defineEmits<{
(event: 'saved'): void
(event: 'saved', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
(event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>()
defineExpose({
show,
showNew,
restoreWithNewTexture,
hide,
shouldRestoreModal,
restoreModal,
})
</script>
@@ -1,141 +0,0 @@
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CapeButton,
CapeLikeTextButton,
ScrollablePanel,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
const modal = useTemplateRef('modal')
const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void
(e: 'cancel'): void
}>()
const props = defineProps<{
capes: Cape[]
}>()
const sortedCapes = computed(() => {
return [...props.capes].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
const currentSkinId = ref<string | undefined>()
const currentSkinTexture = ref<string | undefined>()
const currentSkinVariant = ref<SkinModel>('CLASSIC')
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
const currentCape = ref<Cape | undefined>()
function show(
e: MouseEvent,
skinId?: string,
selected?: Cape,
skinTexture?: string,
variant?: SkinModel,
) {
currentSkinId.value = skinId
currentSkinTexture.value = skinTexture
currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected
modal.value?.show(e)
}
function select() {
emit('select', currentCape.value)
hide()
}
function hide() {
modal.value?.hide()
emit('cancel')
}
function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape
}
function onModalHide() {
emit('cancel')
}
defineExpose({
show,
hide,
})
</script>
<template>
<ModalWrapper ref="modal" @on-hide="onModalHide">
<template #title>
<div class="flex flex-col">
<span class="text-lg font-extrabold text-heading">Change cape</span>
</div>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
v-if="currentSkinTexture"
:cape-src="currentCapeTexture"
:texture-src="currentSkinTexture"
:variant="currentSkinVariant"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI + Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full my-auto">
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
<CapeLikeTextButton
tooltip="No Cape"
:highlighted="!currentCape"
@click="updateSelectedCape(undefined)"
>
<template #icon>
<XIcon />
</template>
<span>None</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:name="cape.name"
:texture="cape.texture"
:selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)"
/>
</div>
</ScrollablePanel>
</div>
</div>
<div class="flex gap-2 items-center">
<ButtonStyled color="brand">
<button @click="select">
<CheckIcon />
Select
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>
@@ -1,141 +0,0 @@
<template>
<ModalWrapper ref="modal" @on-hide="hide(true)">
<template #title>
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
</template>
<div class="relative">
<div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput"
>
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
<UploadIcon /> Select skin texture file
</p>
<p class="mx-auto mt-0 text-secondary text-sm text-center">
Drag and drop or click here to browse
</p>
<input
ref="fileInput"
type="file"
accept="image/png"
class="hidden"
@change="handleInputFileChange"
/>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { onBeforeUnmount, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
const { addNotification } = injectNotificationManager()
const modal = ref()
const fileInput = ref<HTMLInputElement>()
const unlisten = ref<() => void>()
const modalVisible = ref(false)
const emit = defineEmits<{
(e: 'uploaded', data: ArrayBuffer): void
(e: 'canceled'): void
}>()
function show(e?: MouseEvent) {
modal.value?.show(e)
modalVisible.value = true
setupDragDropListener()
}
function hide(emitCanceled = false) {
modal.value?.hide()
modalVisible.value = false
cleanupDragDropListener()
resetState()
if (emitCanceled) {
emit('canceled')
}
}
function resetState() {
if (fileInput.value) fileInput.value.value = ''
}
function triggerFileInput() {
fileInput.value?.click()
}
async function handleInputFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) {
return
}
const file = files[0]
const buffer = await file.arrayBuffer()
await processData(buffer)
}
async function setupDragDropListener() {
try {
if (modalVisible.value) {
await cleanupDragDropListener()
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') {
return
}
if (!event.payload.paths || event.payload.paths.length === 0) {
return
}
const filePath = event.payload.paths[0]
try {
const data = await get_dragged_skin_data(filePath)
await processData(data.buffer)
} catch (error) {
addNotification({
title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error',
})
}
})
}
} catch (error) {
console.error('Failed to set up drag and drop listener:', error)
}
}
async function cleanupDragDropListener() {
if (unlisten.value) {
unlisten.value()
unlisten.value = undefined
}
}
async function processData(buffer: ArrayBuffer) {
emit('uploaded', buffer)
hide()
}
watch(modalVisible, (isVisible) => {
if (isVisible) {
setupDragDropListener()
} else {
cleanupDragDropListener()
}
})
onBeforeUnmount(() => {
cleanupDragDropListener()
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,410 @@
<script setup lang="ts">
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
commonMessages,
defineMessages,
SkinButton,
SkinLikeTextButton,
useScrollViewport,
useVIntl,
} from '@modrinth/ui'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins.ts'
type SkinSectionKind = 'saved' | 'default'
type SkinLikeTextButtonExpose = {
getRootElement: () => HTMLElement | null | undefined
}
type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
interface DefaultSkinSection {
title: string
skins: Skin[]
}
interface SkinSection {
key: string
title: string
kind: SkinSectionKind
skins: Skin[]
}
interface VirtualSkinSection {
section: SkinSection
top: number
index: number
}
const SKIN_CARD_ASPECT_WIDTH = 31
const SKIN_CARD_ASPECT_HEIGHT = 40
const SKIN_GRID_GAP = 12
const SKIN_SECTION_FIRST_SPACING = 4
const SKIN_SECTION_SPACING = 24
const SKIN_SECTION_HEADER_HEIGHT = 28
const SKIN_SECTION_CONTENT_SPACING = 8
const SKIN_SECTION_OVERSCAN = 900
const FALLBACK_CARD_WIDTH = 220
const messages = defineMessages({
savedSkinsSection: {
id: 'app.skins.section.saved-skins',
defaultMessage: 'Saved skins',
},
addSkinButton: {
id: 'app.skins.add-button',
defaultMessage: 'Add skin',
},
dragAndDropSubtitle: {
id: 'app.skins.add-button.drag-and-drop',
defaultMessage: 'Drag and drop',
},
editSkinButton: {
id: 'app.skins.edit-button',
defaultMessage: 'Edit skin',
},
deleteSkinButton: {
id: 'app.skins.delete-button',
defaultMessage: 'Delete skin',
},
})
const props = defineProps<{
savedSkins: Skin[]
defaultSkinSections: DefaultSkinSection[]
getBakedSkinTextures: (skin: Skin) => RenderResult | undefined
isSkinSelected: (skin: Skin) => boolean
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
}>()
const emit = defineEmits<{
select: [skin: Skin]
edit: [skin: Skin, event: MouseEvent]
delete: [skin: Skin]
'add-skin': []
'add-skin-dragenter': [event: DragEvent]
'add-skin-dragover': [event: DragEvent]
'add-skin-dragleave': [event: DragEvent]
'add-skin-drop': [event: DragEvent]
}>()
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
const { formatMessage } = useVIntl()
const { listContainer, relativeScrollTop, scrollContainer, viewportHeight } = useScrollViewport()
const openSectionKeys = ref<Set<string>>(new Set())
const hasSettledInitialLayout = ref(false)
const knownSectionKeys = new Set<string>()
let enableLayoutTransitionsFrame: number | null = null
let isEnableLayoutTransitionsScheduled = false
let isUnmounted = false
const { width: listWidth } = useElementSize(listContainer)
const { width: windowWidth } = useWindowSize()
const columnCount = computed(() => {
if (windowWidth.value >= 2050) {
return 6
}
if (windowWidth.value >= 1750) {
return 5
}
if (windowWidth.value >= 1300) {
return 4
}
return 3
})
const cardWidth = computed(() => {
if (listWidth.value <= 0) {
return FALLBACK_CARD_WIDTH
}
const gapsWidth = (columnCount.value - 1) * SKIN_GRID_GAP
return Math.max(0, (listWidth.value - gapsWidth) / columnCount.value)
})
const cardHeight = computed(
() => (cardWidth.value * SKIN_CARD_ASPECT_HEIGHT) / SKIN_CARD_ASPECT_WIDTH,
)
const sections = computed<SkinSection[]>(() => [
{
key: 'saved-skins',
title: formatMessage(messages.savedSkinsSection),
kind: 'saved',
skins: props.savedSkins,
},
...props.defaultSkinSections.map((section) => ({
key: defaultSkinSectionKey(section.title),
title: section.title,
kind: 'default' as const,
skins: section.skins,
})),
])
const sectionLayouts = computed(() => {
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
let top = 0
sections.value.forEach((section, index) => {
const height = getSectionHeightEstimate(section, index)
layouts.push({ section, top, height, index })
top += height
})
return layouts
})
const totalHeight = computed(() => {
const lastSection = sectionLayouts.value[sectionLayouts.value.length - 1]
return lastSection ? lastSection.top + lastSection.height : 0
})
const visibleSections = computed<VirtualSkinSection[]>(() => {
if (!listContainer.value || !scrollContainer.value) {
return sectionLayouts.value.slice(0, 4)
}
const viewportStart = Math.max(0, relativeScrollTop.value - SKIN_SECTION_OVERSCAN)
const viewportEnd = relativeScrollTop.value + viewportHeight.value + SKIN_SECTION_OVERSCAN
return sectionLayouts.value
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
.map(({ section, top, index }) => ({ section, top, index }))
})
watch(
sections,
(nextSections) => {
const sectionKeys = new Set(nextSections.map((section) => section.key))
const openKeys = new Set(openSectionKeys.value)
for (const section of nextSections) {
if (!knownSectionKeys.has(section.key)) {
knownSectionKeys.add(section.key)
openKeys.add(section.key)
}
}
for (const key of knownSectionKeys) {
if (!sectionKeys.has(key)) {
knownSectionKeys.delete(key)
openKeys.delete(key)
}
}
openSectionKeys.value = openKeys
},
{ immediate: true },
)
watch(
listWidth,
(width) => {
if (
typeof window === 'undefined' ||
width <= 0 ||
hasSettledInitialLayout.value ||
isEnableLayoutTransitionsScheduled
) {
return
}
isEnableLayoutTransitionsScheduled = true
void nextTick(() => {
if (isUnmounted) return
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
if (isUnmounted) return
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
if (isUnmounted) return
hasSettledInitialLayout.value = true
enableLayoutTransitionsFrame = null
isEnableLayoutTransitionsScheduled = false
})
})
})
},
{ immediate: true },
)
onUnmounted(() => {
isUnmounted = true
if (enableLayoutTransitionsFrame !== null) {
window.cancelAnimationFrame(enableLayoutTransitionsFrame)
}
})
function defaultSkinSectionKey(title: string) {
return `default-skins-${title}`
}
function skinKey(skin: Skin, prefix: string) {
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
}
function isSectionOpen(key: string) {
return openSectionKeys.value.has(key)
}
function setSectionOpen(key: string, open: boolean) {
const openKeys = new Set(openSectionKeys.value)
if (open) {
openKeys.add(key)
} else {
openKeys.delete(key)
}
openSectionKeys.value = openKeys
}
function getSectionHeightEstimate(section: SkinSection, index: number) {
const spacing = index === 0 ? SKIN_SECTION_FIRST_SPACING : SKIN_SECTION_SPACING
if (!isSectionOpen(section.key)) {
return spacing + SKIN_SECTION_HEADER_HEIGHT
}
const cardCount = section.kind === 'saved' ? section.skins.length + 1 : section.skins.length
const rowCount = Math.ceil(cardCount / columnCount.value)
const gridHeight = rowCount * cardHeight.value + Math.max(0, rowCount - 1) * SKIN_GRID_GAP
return spacing + SKIN_SECTION_HEADER_HEIGHT + SKIN_SECTION_CONTENT_SPACING + gridHeight
}
function getAddSkinButtonElement() {
const button = Array.isArray(addSkinButton.value)
? addSkinButton.value.find((candidate) => candidate.getRootElement())
: addSkinButton.value
return button?.getRootElement()
}
defineExpose({ getAddSkinButtonElement })
</script>
<template>
<div
ref="listContainer"
class="relative w-full"
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
>
<div
v-for="{ section, top, index } in visibleSections"
:key="section.key"
class="absolute inset-x-0"
:class="[
index === 0 ? 'pt-1' : 'pt-6',
hasSettledInitialLayout
? 'transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none'
: '',
]"
:style="{ transform: `translateY(${top}px)` }"
>
<Accordion
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
content-class="pt-2"
:open-by-default="isSectionOpen(section.key)"
@on-open="setSectionOpen(section.key, true)"
@on-close="setSectionOpen(section.key, false)"
>
<template #title>
{{ section.title }}
</template>
<template #button="{ open }">
<DropdownIcon
class="size-6 shrink-0 text-primary transition-transform duration-300"
:class="{ 'rotate-180': open }"
/>
<span class="min-w-0 text-xl font-semibold leading-7 text-primary">
{{ section.title }}
</span>
</template>
<div
v-if="section.kind === 'saved'"
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:drag-active="isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
<SkinButton
v-for="skin in section.skins"
:key="skinKey(skin, 'saved-skin')"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
@select="emit('select', skin)"
>
<template #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
<div
v-else
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
>
<SkinButton
v-for="skin in section.skins"
:key="skinKey(skin, section.key)"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:tooltip="skin.name"
@select="emit('select', skin)"
/>
</div>
</Accordion>
</div>
</div>
</template>
@@ -244,7 +244,7 @@ const messages = defineMessages({
</TagItem>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<UserIcon
aria-hidden="true"
@@ -3,9 +3,8 @@ import {
applyCapeTexture,
createTransparentTexture,
disposeCaches,
loadTexture,
setupSkinModel,
} from '@modrinth/utils'
} from '@modrinth/ui'
import * as THREE from 'three'
import { reactive } from 'vue'
@@ -29,6 +28,7 @@ class BatchSkinRenderer {
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null
private transparentTexture: THREE.Texture | null = null
private readonly width: number
private readonly height: number
@@ -52,6 +52,7 @@ class BatchSkinRenderer {
})
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.shadowMap.enabled = false
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
@@ -62,7 +63,7 @@ class BatchSkinRenderer {
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.castShadow = false
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
@@ -112,9 +113,19 @@ class BatchSkinRenderer {
this.renderer.render(this.scene, this.camera)
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
const response = await fetch(dataUrl)
return await response.blob()
return await new Promise<Blob>((resolve, reject) => {
this.renderer!.domElement.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from rendered canvas'))
}
},
'image/webp',
0.9,
)
})
}
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
@@ -122,14 +133,10 @@ class BatchSkinRenderer {
throw new Error('Renderer not initialized')
}
const { model } = await setupSkinModel(modelUrl, textureUrl)
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
if (capeUrl) {
const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture)
} else {
const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture)
if (!capeUrl) {
applyCapeTexture(model, null, this.getTransparentTexture())
}
const group = new THREE.Group()
@@ -141,39 +148,38 @@ class BatchSkinRenderer {
this.currentModel = group
}
private clearScene(): void {
if (!this.scene) return
while (this.scene.children.length > 0) {
const child = this.scene.children[0]
this.scene.remove(child)
if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material.dispose()
}
}
}
private getTransparentTexture(): THREE.Texture {
if (!this.transparentTexture) {
this.transparentTexture = createTransparentTexture()
}
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
return this.transparentTexture
}
private clearScene(): void {
if (!this.scene || !this.currentModel) return
this.scene.remove(this.currentModel)
this.currentModel.clear()
this.currentModel = null
}
public dispose(): void {
this.clearScene()
if (this.transparentTexture) {
this.transparentTexture.dispose()
this.transparentTexture = null
}
if (this.renderer) {
this.renderer.dispose()
}
this.renderer = null
this.scene = null
this.camera = null
disposeCaches()
}
}
@@ -194,6 +200,9 @@ export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0
let previewGenerationQueue: Promise<void> = Promise.resolve()
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
@@ -356,7 +365,27 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
return await generateHeadRender(skin)
}
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const generation = ++latestPreviewGeneration
const skinsSnapshot = [...skins]
const capesSnapshot = [...capes]
const generationPromise = previewGenerationQueue.then(() =>
generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
)
previewGenerationQueue = generationPromise.catch(() => {})
return generationPromise
}
async function generateSkinPreviewsForGeneration(
skins: Skin[],
capes: Cape[],
generation: number,
): Promise<void> {
const isCurrentGeneration = () => generation === latestPreviewGeneration
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
@@ -368,6 +397,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
headStorage.batchRetrieve(headKeys),
])
if (!isCurrentGeneration()) return
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
@@ -388,6 +419,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
}
for (const skin of skins) {
if (!isCurrentGeneration()) return
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (skinBlobUrlMap.has(key)) {
@@ -419,6 +452,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
cape?.texture,
)
if (!isCurrentGeneration()) return
const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
@@ -439,9 +474,12 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
}
} finally {
disposeSharedRenderer()
await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
if (isCurrentGeneration()) {
await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
}
}
}
+30 -10
View File
@@ -5,7 +5,6 @@ export interface Cape {
id: string
name: string
texture: string
is_default: boolean
is_equipped: boolean
}
@@ -15,6 +14,7 @@ export type SkinSource = 'default' | 'custom_external' | 'custom'
export interface Skin {
texture_key: string
name?: string
section?: string
variant: SkinModel
cape_id?: string
texture: string
@@ -121,17 +121,11 @@ export async function get_available_skins(): Promise<Skin[]> {
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,
capeOverride?: Cape,
): Promise<void> {
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
cape?: Cape,
): Promise<Skin> {
return await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
textureBlob,
variant,
capeOverride,
})
}
export async function set_default_cape(cape?: Cape): Promise<void> {
await invoke('plugin:minecraft-skins|set_default_cape', {
cape,
})
}
@@ -148,6 +142,22 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
})
}
export async function save_custom_skin(
skin: Skin,
textureBlob: Uint8Array,
variant: SkinModel,
cape: Cape | undefined,
replaceTexture: boolean,
): Promise<Skin> {
return await invoke('plugin:minecraft-skins|save_custom_skin', {
skin,
textureBlob,
variant,
cape,
replaceTexture,
})
}
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data)
@@ -162,6 +172,16 @@ export async function unequip_skin(): Promise<void> {
await invoke('plugin:minecraft-skins|unequip_skin')
}
export async function flush_pending_skin_change(): Promise<void> {
await invoke('plugin:minecraft-skins|flush_pending_skin_change')
}
export async function flush_pending_skin_change_for_profile(profileId: string): Promise<void> {
await invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', {
profileId,
})
}
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
return new Uint8Array(data)
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Procházet servery"
},
"app.instance.worlds.delete-world-description": {
"message": "\"{name}\" bude **navždy smazáno** a nebude způsob, jak to obnovit."
},
"app.instance.worlds.delete-world-title": {
"message": "Opravdu si jsi jistý, že chceš navždy smazat tenhle svět?"
},
@@ -248,9 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Žádné servery nebo světy nebyly přidány"
},
"app.instance.worlds.remove-server-description": {
"message": "„{name}“ bude odstraněn z tvého seznamu, včetně hry, a nebude žádný způsob, jak ho obnovit."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "'{name}' ({address}) bude odstraněn z vašeho seznamu, a to i ve hře, a nebude možné jej obnovit."
},
@@ -443,9 +437,6 @@
"friends.heading.pending": {
"message": "Čeká na vyřízení"
},
"friends.no-friends-match": {
"message": "Žádní přátelé neodpovídají '{query}''"
},
"friends.search-friends-placeholder": {
"message": "Hledat přátele..."
},
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Server durchsuchen"
},
"app.instance.worlds.delete-world-description": {
"message": "„{name}“ wird **endgültig gelöscht**, und es gibt keine Möglichkeit, sie wiederherzustellen."
},
"app.instance.worlds.delete-world-title": {
"message": "Möchtest du diese Welt wirklich endgültig löschen?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Keine Server oder Welten hinzugefügt"
},
"app.instance.worlds.remove-server-description": {
"message": "„{name}“ wird aus deiner Liste entfernt, auch im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "„{name}“ ({address}) wird aus deiner Liste entfernt, inklusive im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
},
"app.instance.worlds.remove-server-title": {
"message": "Möchtest du {name} wirklich entfernen?"
},
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Server durchsuchen"
},
"app.instance.worlds.delete-world-description": {
"message": "„{name}“ wird **endgültig gelöscht**, und es gibt keine Möglichkeit, sie wiederherzustellen."
},
"app.instance.worlds.delete-world-title": {
"message": "Möchtest du diese Welt wirklich endgültig löschen?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Keine Server oder Welten hinzugefügt"
},
"app.instance.worlds.remove-server-description": {
"message": "„{name}“ wird aus deiner Liste entfernt, auch im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "„{name}“ ({address}) wird aus deiner Liste entfernt, inklusive im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
},
"app.instance.worlds.remove-server-title": {
"message": "Möchtest du {name} wirklich entfernen?"
},
@@ -332,6 +332,138 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.skins.add-button": {
"message": "Add skin"
},
"app.skins.add-button.drag-and-drop": {
"message": "Drag and drop"
},
"app.skins.apply-button": {
"message": "Apply"
},
"app.skins.delete-button": {
"message": "Delete skin"
},
"app.skins.delete-modal.description": {
"message": "This will permanently delete the selected skin. This action cannot be undone."
},
"app.skins.delete-modal.title": {
"message": "Are you sure you want to delete this skin?"
},
"app.skins.dropped-file-error.text": {
"message": "Failed to read the dropped file."
},
"app.skins.dropped-file-error.title": {
"message": "Error processing file"
},
"app.skins.edit-button": {
"message": "Edit skin"
},
"app.skins.modal.add-skin-button": {
"message": "Add skin"
},
"app.skins.modal.add-title": {
"message": "Adding a skin"
},
"app.skins.modal.arm-style-section": {
"message": "Arm style"
},
"app.skins.modal.arm-style-slim": {
"message": "Slim"
},
"app.skins.modal.arm-style-wide": {
"message": "Wide"
},
"app.skins.modal.cape-fallback-name": {
"message": "Cape"
},
"app.skins.modal.cape-section": {
"message": "Cape"
},
"app.skins.modal.edit-title": {
"message": "Editing skin"
},
"app.skins.modal.make-edit-first-tooltip": {
"message": "Make an edit to the skin first!"
},
"app.skins.modal.no-cape-tooltip": {
"message": "No cape"
},
"app.skins.modal.none-cape-option": {
"message": "None"
},
"app.skins.modal.replace-texture-button": {
"message": "Replace texture"
},
"app.skins.modal.save-skin-button": {
"message": "Save skin"
},
"app.skins.modal.saving-tooltip": {
"message": "Saving..."
},
"app.skins.modal.texture-section": {
"message": "Texture"
},
"app.skins.modal.upload-skin-first-tooltip": {
"message": "Upload a skin first!"
},
"app.skins.preview.edit-button": {
"message": "Edit skin"
},
"app.skins.previewing-badge": {
"message": "Previewing"
},
"app.skins.rate-limit.text": {
"message": "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again."
},
"app.skins.rate-limit.title": {
"message": "Slow down!"
},
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
"app.skins.section.default-skins": {
"message": "Default skins"
},
"app.skins.section.minecon-earth-2017": {
"message": "MINECON Earth 2017"
},
"app.skins.section.mounts-of-mayhem": {
"message": "Mounts of Mayhem"
},
"app.skins.section.saved-skins": {
"message": "Saved skins"
},
"app.skins.section.striding-hero": {
"message": "Striding Hero"
},
"app.skins.section.the-copper-age": {
"message": "The Copper Age"
},
"app.skins.section.the-garden-awakens": {
"message": "The Garden Awakens"
},
"app.skins.section.tiny-takeover": {
"message": "Tiny Takeover"
},
"app.skins.sign-in.button": {
"message": "Sign In"
},
"app.skins.sign-in.description": {
"message": "Please sign into your Minecraft account to use the skin management features of the Modrinth app."
},
"app.skins.sign-in.rinthbot-alt": {
"message": "Excited Modrinth Bot"
},
"app.skins.sign-in.title": {
"message": "Please sign in"
},
"app.skins.title": {
"message": "Skin selector"
},
"app.update-popup.body": {
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
},
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Explorar servidores"
},
"app.instance.worlds.delete-world-description": {
"message": "\"{name}\" se **eliminará permanentemente** y no habrá forma de recuperarlo."
},
"app.instance.worlds.delete-world-title": {
"message": "¿Estás seguro de que quieres eliminar este mundo de forma permanente?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "No hay servidores ni mundos añadidos"
},
"app.instance.worlds.remove-server-description": {
"message": "\"{name}\" se eliminará de tu lista, también dentro del juego, y no podrás recuperarlo."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "\"{name}\" ({address}) se eliminará de tu lista, también dentro del juego, y no podrás recuperarlo."
},
"app.instance.worlds.remove-server-title": {
"message": "¿Estás seguro de que quieres eliminar {name}?"
},
@@ -611,6 +602,24 @@
"instance.settings.tabs.java": {
"message": "Java y memoria"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables de entorno personalizadas"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Argumentos personalizados de Java"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Instalación personalizada de Java"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Asignación de memoria personalizada"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Introduce las variables de entorno..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Introduce los argumentos de Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variables de entorno"
},
@@ -626,6 +635,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memoria asignada"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/ruta/a/java"
},
"instance.settings.tabs.window": {
"message": "Ventana"
},
+22 -1
View File
@@ -135,7 +135,7 @@
"message": "Ocultar servidores ya añadidos"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
"message": "Paquetes de Mods"
},
"app.browse.server-instance-content-warning": {
"message": "Añadir contenido puede comprometer la compatibilidad a la hora de unirse a un servidor. Cualquier contenido añadido se perderá cuando actualices la instancia del servidor."
@@ -611,6 +611,24 @@
"instance.settings.tabs.java": {
"message": "Java y memoria"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables de entorno personalizadas"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Argumentos personalizados de Java"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Instalación personalizada de Java"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Asignación de memoria personalizada"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Introduce las variables de entorno..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Introduce los argumentos de Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variables de entorno"
},
@@ -626,6 +644,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memoria asignada"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/ruta/a/java"
},
"instance.settings.tabs.window": {
"message": "Ventana"
},
+18 -12
View File
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Parcourir des serveurs"
},
"app.instance.worlds.delete-world-description": {
"message": "« {name} » sera supprimé **pour toujours**, et il sera impossible de le récupérer."
},
"app.instance.worlds.delete-world-title": {
"message": "Êtes-vous sûr·e de vouloir supprimer ce monde pour toujours ?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Aucun serveur ou monde ajoutés"
},
"app.instance.worlds.remove-server-description": {
"message": "« {name} » sera retiré de votre liste, y compris en jeu, et il sera impossible de le récupérer."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "« {name} » ({address}) sera retiré de votre liste, y compris en jeu, et il sera impossible de le récupérer."
},
"app.instance.worlds.remove-server-title": {
"message": "Êtes-vous sûr·e de vouloir retirer {name} ?"
},
@@ -389,9 +380,6 @@
"app.world.world-item.offline": {
"message": "Hors ligne"
},
"app.world.world-item.players-online": {
"message": "{count, plural, =0 {Personne en ligne} one {# est en ligne} other {# sont en ligne}}"
},
"friends.action.add-friend": {
"message": "Ajouter un ami"
},
@@ -611,6 +599,24 @@
"instance.settings.tabs.java": {
"message": "Java et mémoire"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables d'environnement personnalisé"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Arguments Java personnalisée"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Installation Java personnalisée"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Allocation de mémoire personnalisée"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Entrer une variable d'environnement..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Entrer un argument Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variables d'environnement"
},
@@ -254,9 +254,6 @@
"friends.action.add-friend": {
"message": "הוספת חבר"
},
"friends.action.view-friend-requests": {
"message": "{count, plural, one {בקשת חברות אחת} other {# בקשות חברות}}"
},
"friends.add-friend.submit": {
"message": "שליחת בקשת חברות"
},
@@ -272,9 +272,6 @@
"app.modal.install-to-play.install-button": {
"message": "Telepítés"
},
"app.modal.install-to-play.mod-count": {
"message": "{count} mod"
},
"app.modal.install-to-play.required-modpack": {
"message": "Szükséges modcsomag"
},
@@ -395,9 +392,6 @@
"friends.action.add-friend": {
"message": "Barát hozzáadása"
},
"friends.action.view-friend-requests": {
"message": "{count} barát {count, plural, other {kérelem}}"
},
"friends.add-friend.submit": {
"message": "Barátkérelem elküldése"
},
@@ -245,9 +245,6 @@
"app.modal.install-to-play.install-button": {
"message": "Pasang"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {# mod}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Paket mod yang diperlukan"
},
@@ -362,9 +359,6 @@
"friends.action.add-friend": {
"message": "Tambah teman"
},
"friends.action.view-friend-requests": {
"message": "{count} {count, plural, other {permintaan}} teman"
},
"friends.add-friend.submit": {
"message": "Kirim permintaan teman"
},
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Esplora i server"
},
"app.instance.worlds.delete-world-description": {
"message": "\"{name}\" verrà **eliminato permanentemente** e non ci sarà modo di recuperarlo."
},
"app.instance.worlds.delete-world-title": {
"message": "Vuoi davvero eliminare questo mondo per sempre?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Nessun server o mondo aggiunto"
},
"app.instance.worlds.remove-server-description": {
"message": "\"{name}\" sarà rimosso dalla tua lista, inclusa quella nel gioco, e non ci sarà modo di recuperarlo."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "\"{name}\" ({address}) sarà rimosso dalla tua lista, inclusa quella nel gioco, e non ci sarà modo di recuperarlo."
},
"app.instance.worlds.remove-server-title": {
"message": "Vuoi davvero rimuovere {name}?"
},
@@ -272,9 +263,6 @@
"app.modal.install-to-play.install-button": {
"message": "Installa"
},
"app.modal.install-to-play.mod-count": {
"message": "{count} mod"
},
"app.modal.install-to-play.required-modpack": {
"message": "Pacchetto di mod richiesto"
},
@@ -254,9 +254,6 @@
"app.modal.install-to-play.install-button": {
"message": "インストール"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {#個のMod}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "必須のModパック"
},
@@ -368,9 +365,6 @@
"friends.action.add-friend": {
"message": "フレンドを追加"
},
"friends.action.view-friend-requests": {
"message": "{count}件の友達リクエスト"
},
"friends.add-friend.submit": {
"message": "フレンド申請を送信"
},
@@ -395,9 +395,6 @@
"friends.action.add-friend": {
"message": "친구 추가"
},
"friends.action.view-friend-requests": {
"message": "{count}건의 친구 요청"
},
"friends.add-friend.submit": {
"message": "친구 요청 보내기"
},
@@ -272,9 +272,6 @@
"app.modal.install-to-play.install-button": {
"message": "Pasang"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {# mod}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Pek mod yang diperlukan"
},
@@ -392,9 +389,6 @@
"friends.action.add-friend": {
"message": "Tambah rakan"
},
"friends.action.view-friend-requests": {
"message": "{count} {count, plural, other {permintaan}} rakan"
},
"friends.add-friend.submit": {
"message": "Hantar permintaan rakan"
},
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Zoek servers"
},
"app.instance.worlds.delete-world-description": {
"message": "{name} Zal **permanent verwijderd** worden en kan niet hersteld worden."
},
"app.instance.worlds.delete-world-title": {
"message": "Weet je zeker dat je deze wereld definitief wilt verwijderen?"
},
@@ -257,9 +254,6 @@
"app.instance.worlds.remove-server-title": {
"message": "Ben je zeker dat je {name} wil verwijderen?"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Zoek werelden"
},
"app.instance.worlds.this-server": {
"message": "deze server"
},
@@ -206,9 +206,6 @@
"app.instance.mods.project-was-added": {
"message": "Dodano \"{name}\""
},
"app.instance.mods.projects-were-added": {
"message": "Dodano {count} {count, plural, one {projekt} few {projekty} other {projektów}}"
},
"app.instance.mods.share-text": {
"message": "Sprawdź projekty, które używam w mojej paczce modów!"
},
@@ -272,9 +269,6 @@
"app.modal.install-to-play.install-button": {
"message": "Zainstaluj"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} few {# mody} other {# modów}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Wymagana paczka modów"
},
@@ -395,9 +389,6 @@
"friends.action.add-friend": {
"message": "Dodaj znajomego"
},
"friends.action.view-friend-requests": {
"message": "{count} {count, plural, one {zaproszenie} few {zaproszenia} other {zaproszeń}} do znajomych"
},
"friends.add-friend.submit": {
"message": "Wyślij zaproszenie"
},
@@ -272,9 +272,6 @@
"app.modal.install-to-play.install-button": {
"message": "Instalar"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, =0 {Nenhum mod} one {# mod} other {# mods}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Pacote de mods necessário"
},
@@ -395,9 +392,6 @@
"friends.action.add-friend": {
"message": "Adicionar um amigo"
},
"friends.action.view-friend-requests": {
"message": "{count, plural, =0 {Nenhuma solicitação de amizade} one {{count} solicitação de amizade} other {{count} solicitações de amizade}}"
},
"friends.add-friend.submit": {
"message": "Enviar amizade"
},
@@ -645,7 +639,7 @@
"message": "Memória alocada"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/caminho/para/java"
"message": "/caminho/ao/java"
},
"instance.settings.tabs.window": {
"message": "Janela"
@@ -149,9 +149,6 @@
"app.instance.worlds.browse-servers": {
"message": "Procurar servidores"
},
"app.instance.worlds.delete-world-description": {
"message": "\"{name}\" vai ser **apagado permanentemente**, e não haverá maneira de o recuperar."
},
"app.instance.worlds.delete-world-title": {
"message": "Tens a certeza que queres apagar permanentemente este mundo?"
},
@@ -173,12 +170,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Sem servidores ou mundos adicionados"
},
"app.instance.worlds.remove-server-description": {
"message": "\"{name}\" vai ser removido da tua lista, incluindo dentro do jogo, e não haverá maneira de o recuperar."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "\"{name}\" ({address}) vai ser removido da tua lista, incluindo dentro do jogo, e não haverá maneira de o recuperar."
},
"app.instance.worlds.remove-server-title": {
"message": "Tens a certeza que queres remover {name}?"
},
@@ -206,9 +206,6 @@
"app.instance.mods.project-was-added": {
"message": "«{name}» добавлен"
},
"app.instance.mods.projects-were-added": {
"message": "{count, plural, one {Добавлен # проект} few {Добавлено # проекта} other {Добавлено # проектов}}"
},
"app.instance.mods.share-text": {
"message": "Что в моей сборке:"
},
@@ -224,9 +221,6 @@
"app.instance.worlds.browse-servers": {
"message": "Найти серверы"
},
"app.instance.worlds.delete-world-description": {
"message": "«{name}» будет **удалён навсегда**. Его невозможно восстановить."
},
"app.instance.worlds.delete-world-title": {
"message": "Вы действительно хотите удалить этот мир?"
},
@@ -248,18 +242,9 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Нет серверов и миров"
},
"app.instance.worlds.remove-server-description": {
"message": "«{name}» будет удалён из списка — в том числе в игре. Его невозможно восстановить."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "«{name}» ({address}) будет удалён из списка — в том числе в игре. Его невозможно восстановить."
},
"app.instance.worlds.remove-server-title": {
"message": "Вы действительно хотите удалить {name}?"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Поиск по {count, plural, one {# миру} other {# мирам}}..."
},
"app.instance.worlds.this-server": {
"message": "этот сервер"
},
@@ -272,9 +257,6 @@
"app.modal.install-to-play.install-button": {
"message": "Установить"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# мод} few {# мода} other {# модов}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Необходимая сборка"
},
@@ -395,9 +377,6 @@
"friends.action.add-friend": {
"message": "Добавить в друзья"
},
"friends.action.view-friend-requests": {
"message": "{count} {count, plural, one {запрос} few {запроса} other {запросов}} дружбы"
},
"friends.add-friend.submit": {
"message": "Отправить запрос дружбы"
},
@@ -491,9 +470,6 @@
"instance.edit-world.title": {
"message": "Настройка мира"
},
"instance.files.adding-files": {
"message": "Добавление файлов ({completed, number}/{total, number})"
},
"instance.files.save-as": {
"message": "Сохранить как..."
},
@@ -260,9 +260,6 @@
"app.modal.install-to-play.install-button": {
"message": "ติดตั้ง"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {# ม็อด}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "มอดแพ็กที่จำเป็น"
},
@@ -374,9 +371,6 @@
"friends.action.add-friend": {
"message": "เพิ่มเพื่อน"
},
"friends.action.view-friend-requests": {
"message": "คำขอเป็นเพื่อน {count} {count, plural, other {คน}}"
},
"friends.add-friend.submit": {
"message": "ส่งคำขอเป็นเพื่อน"
},
@@ -368,9 +368,6 @@
"friends.action.add-friend": {
"message": "Bir arkadaş ekle"
},
"friends.action.view-friend-requests": {
"message": "{count} arkadaşlık isteği"
},
"friends.add-friend.submit": {
"message": "Arkadaşlık isteği gönder"
},
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Переглянути сервери"
},
"app.instance.worlds.delete-world-description": {
"message": "«{name}» буде **назавжди видалено** й у вас не буде можливости відновлення."
},
"app.instance.worlds.delete-world-title": {
"message": "Ви впевнені, що хочете назавжди видалити цей світ?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Сервера або світи не додано"
},
"app.instance.worlds.remove-server-description": {
"message": "«{name}» буде видалено з вашого списку, включно в грі, і без можливости відновлення."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "«{name}» ({address}) буде видалено з вашого списку, включно в грі, і без можливости відновлення."
},
"app.instance.worlds.remove-server-title": {
"message": "Ви впевнені, що хочете видалити «{name}»?"
},
@@ -272,9 +263,6 @@
"app.modal.install-to-play.install-button": {
"message": "Установити"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# мод} few {# мода} other {# модів}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Потрібна збірка"
},
@@ -395,9 +383,6 @@
"friends.action.add-friend": {
"message": "Додати друга"
},
"friends.action.view-friend-requests": {
"message": "{count} запит{count, plural, one { у} few {и в} many {ів у} other {у в}} друзі"
},
"friends.add-friend.submit": {
"message": "Надіслати запит у друзі"
},
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "浏览服务器"
},
"app.instance.worlds.delete-world-description": {
"message": "“{name}”将被**永久删除**,并且无法恢复。"
},
"app.instance.worlds.delete-world-title": {
"message": "你确定要永久删除这个世界吗?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "未添加服务器或世界"
},
"app.instance.worlds.remove-server-description": {
"message": "将从你的列表中删除“{name}”,包括游戏内,并且无法恢复。"
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "将从你的列表中删除“{name}”({address}),包括游戏内,并且无法恢复。"
},
"app.instance.worlds.remove-server-title": {
"message": "你确定要删除 {name} 吗?"
},
@@ -272,9 +263,6 @@
"app.modal.install-to-play.install-button": {
"message": "安装"
},
"app.modal.install-to-play.mod-count": {
"message": "{count} 个模组"
},
"app.modal.install-to-play.required-modpack": {
"message": "需求整合包"
},
@@ -395,9 +383,6 @@
"friends.action.add-friend": {
"message": "添加好友"
},
"friends.action.view-friend-requests": {
"message": "{count} 个好友请求"
},
"friends.add-friend.submit": {
"message": "发送好友请求"
},
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "瀏覽伺服器"
},
"app.instance.worlds.delete-world-description": {
"message": "「{name}」將**永久刪除**,且無法還原。"
},
"app.instance.worlds.delete-world-title": {
"message": "確定要永久刪除這個世界嗎?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "未新增任何伺服器或世界"
},
"app.instance.worlds.remove-server-description": {
"message": "「{name}」將從你的清單中移除(包含遊戲內),且無法還原。"
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "「{name}」({address}) 將從你的清單中移除(包含遊戲內),且無法還原。"
},
"app.instance.worlds.remove-server-title": {
"message": "確定要移除「{name}」嗎?"
},
@@ -272,9 +263,6 @@
"app.modal.install-to-play.install-button": {
"message": "安裝"
},
"app.modal.install-to-play.mod-count": {
"message": "{count} 個模組"
},
"app.modal.install-to-play.required-modpack": {
"message": "所需模組包"
},
@@ -395,9 +383,6 @@
"friends.action.add-friend": {
"message": "新增好友"
},
"friends.action.view-friend-requests": {
"message": "{count} 個好友邀請"
},
"friends.add-friend.submit": {
"message": "送出好友邀請"
},
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -86,10 +86,10 @@ export default new createRouter({
},
{
path: '/skins',
name: 'Skins',
name: 'Skin selector',
component: Pages.Skins,
meta: {
breadcrumb: [{ name: 'Skins' }],
breadcrumb: [{ name: 'Skin selector' }],
},
},
{
+2
View File
@@ -21,6 +21,7 @@ export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type ThemeStore = {
selectedTheme: ColorTheme
advancedRendering: boolean
hideNametagSkinsPage: boolean
toggleSidebar: boolean
devMode: boolean
@@ -30,6 +31,7 @@ export type ThemeStore = {
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
advancedRendering: true,
hideNametagSkinsPage: false,
toggleSidebar: false,
devMode: false,
+3 -1
View File
@@ -114,10 +114,12 @@ fn main() {
"get_available_capes",
"get_available_skins",
"add_and_equip_custom_skin",
"set_default_cape",
"equip_skin",
"remove_custom_skin",
"save_custom_skin",
"unequip_skin",
"flush_pending_skin_change",
"flush_pending_skin_change_for_profile",
"normalize_skin_texture",
"get_dragged_skin_data",
])
+51 -17
View File
@@ -11,10 +11,12 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_available_capes,
get_available_skins,
add_and_equip_custom_skin,
set_default_cape,
equip_skin,
remove_custom_skin,
save_custom_skin,
unequip_skin,
flush_pending_skin_change,
flush_pending_skin_change_for_profile,
normalize_skin_texture,
get_dragged_skin_data,
])
@@ -37,29 +39,19 @@ pub async fn get_available_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_available_skins().await?)
}
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
///
/// See also: [minecraft_skins::add_and_equip_custom_skin]
#[tauri::command]
pub async fn add_and_equip_custom_skin(
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape_override: Option<Cape>,
) -> Result<()> {
Ok(minecraft_skins::add_and_equip_custom_skin(
texture_blob,
variant,
cape_override,
cape: Option<Cape>,
) -> Result<Skin> {
Ok(
minecraft_skins::add_and_equip_custom_skin(texture_blob, variant, cape)
.await?,
)
.await?)
}
/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
///
/// See also: [minecraft_skins::set_default_cape]
#[tauri::command]
pub async fn set_default_cape(cape: Option<Cape>) -> Result<()> {
Ok(minecraft_skins::set_default_cape(cape).await?)
}
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
@@ -78,6 +70,27 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::remove_custom_skin(skin).await?)
}
/// `invoke('plugin:minecraft-skins|save_custom_skin', skin, texture_blob, variant, cape, replace_texture)`
///
/// See also: [minecraft_skins::save_custom_skin]
#[tauri::command]
pub async fn save_custom_skin(
skin: Skin,
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape: Option<Cape>,
replace_texture: bool,
) -> Result<Skin> {
Ok(minecraft_skins::save_custom_skin(
skin,
texture_blob,
variant,
cape,
replace_texture,
)
.await?)
}
/// `invoke('plugin:minecraft-skins|unequip_skin')`
///
/// See also: [minecraft_skins::unequip_skin]
@@ -86,6 +99,27 @@ pub async fn unequip_skin() -> Result<()> {
Ok(minecraft_skins::unequip_skin().await?)
}
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change')`
///
/// See also: [minecraft_skins::flush_pending_skin_change]
#[tauri::command]
pub async fn flush_pending_skin_change() -> Result<()> {
Ok(minecraft_skins::flush_pending_skin_change().await?)
}
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', profile_id)`
///
/// See also: [minecraft_skins::flush_pending_skin_change_for_profile]
#[tauri::command]
pub async fn flush_pending_skin_change_for_profile(
profile_id: uuid::Uuid,
) -> Result<()> {
Ok(
minecraft_skins::flush_pending_skin_change_for_profile(profile_id)
.await?,
)
}
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
///
/// See also: [minecraft_skins::normalize_skin_texture]
+12 -2
View File
@@ -270,10 +270,20 @@ fn main() {
Ok(app) => {
app.run(|app, event| {
#[cfg(not(any(feature = "updater", target_os = "macos")))]
drop((app, event));
let _ = app;
if matches!(&event, tauri::RunEvent::ExitRequested { .. })
&& let Err(error) = tauri::async_runtime::block_on(
theseus::minecraft_skins::flush_pending_skin_change(),
)
{
tracing::warn!(
"Failed to flush pending Minecraft skin change before exit: {error}"
);
}
#[cfg(feature = "updater")]
if matches!(event, tauri::RunEvent::Exit) {
if matches!(&event, tauri::RunEvent::Exit) {
let update_data = app.state::<PendingUpdateData>().inner();
let should_restart = State::get_if_initialized()
.map(|s| {
+53 -36
View File
@@ -65,49 +65,65 @@ async fn fetch(
)
.await?;
// We check Modrinth's fabric version manifest and compare if the fabric version exists in Modrinth's database
// We also check intermediary versions that are newly added to query
let (fetch_fabric_versions, fetch_intermediary_versions) =
if let Some(modrinth_manifest) = modrinth_manifest {
let (mut fetch_versions, mut fetch_intermediary_versions) =
(Vec::new(), Vec::new());
// We check Modrinth's manifest to find newly added loader versions,
// intermediary/mapping artifacts, and game versions.
let (
fetch_fabric_versions,
fetch_intermediary_versions,
has_new_game_versions,
) = if let Some(modrinth_manifest) = modrinth_manifest {
let (mut fetch_versions, mut fetch_intermediary_versions) =
(Vec::new(), Vec::new());
for version in &fabric_manifest.loader {
if !modrinth_manifest
.game_versions
.iter()
.any(|x| x.loaders.iter().any(|x| x.id == version.version))
&& !skip_versions.contains(&&*version.version)
{
fetch_versions.push(version);
}
for version in &fabric_manifest.loader {
if !modrinth_manifest
.game_versions
.iter()
.any(|x| x.loaders.iter().any(|x| x.id == version.version))
&& !skip_versions.contains(&&*version.version)
{
fetch_versions.push(version);
}
}
for version in &fabric_manifest.intermediary {
if !modrinth_manifest
for version in &fabric_manifest.intermediary {
if !modrinth_manifest
.game_versions
.iter()
.any(|x| x.id == version.version)
&& fabric_manifest
.game
.iter()
.any(|x| x.version == version.version)
{
fetch_intermediary_versions.push(version);
}
}
let has_new_game_versions =
fabric_manifest.game.iter().any(|version| {
!modrinth_manifest
.game_versions
.iter()
.any(|x| x.id == version.version)
&& fabric_manifest
.game
.iter()
.any(|x| x.version == version.version)
{
fetch_intermediary_versions.push(version);
}
}
});
(fetch_versions, fetch_intermediary_versions)
} else {
(
fabric_manifest
.loader
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.collect(),
fabric_manifest.intermediary.iter().collect(),
)
};
(
fetch_versions,
fetch_intermediary_versions,
has_new_game_versions,
)
} else {
(
fabric_manifest
.loader
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.collect(),
fabric_manifest.intermediary.iter().collect(),
true,
)
};
const DUMMY_GAME_VERSION: &str = "1.21";
@@ -216,6 +232,7 @@ async fn fetch(
if !fetch_fabric_versions.is_empty()
|| !fetch_intermediary_versions.is_empty()
|| has_new_game_versions
{
let fabric_manifest_path =
format!("{mod_loader}/v{format_version}/manifest.json",);
-11
View File
@@ -125,8 +125,6 @@ export default defineNuxtConfig({
const docTemplates = Object.keys(
await import('./src/templates/docs/index.ts').then((m) => m.default),
)
const blogArticles = await import('@modrinth/blog').then((m) => m.articles)
const { getChangelog } = await import('@modrinth/blog')
nitroConfig.prerender = nitroConfig.prerender || {}
nitroConfig.prerender.routes = nitroConfig.prerender.routes || []
@@ -136,15 +134,6 @@ export default defineNuxtConfig({
for (const template of docTemplates) {
nitroConfig.prerender.routes.push(`/_internal/templates/doc/${template}`)
}
nitroConfig.prerender.routes.push('/news')
for (const article of blogArticles) {
nitroConfig.prerender.routes.push(`/news/article/${article.slug}`)
}
nitroConfig.prerender.routes.push('/news/changelog')
for (const entry of getChangelog()) {
const id = entry.version ?? entry.date.unix()
nitroConfig.prerender.routes.push(`/news/changelog/${entry.product}/${id}`)
}
},
async 'build:before'() {
// 30 minutes
+2
View File
@@ -11,6 +11,7 @@
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/frontend",
"cf-deploy": "pnpm run build && wrangler deploy --env staging",
"cf-dev": "pnpm run build && wrangler dev --env staging",
"cf-typegen": "wrangler types"
@@ -54,6 +55,7 @@
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"chart.js": "^4.5.1",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
"floating-vue": "^5.2.2",
@@ -50,6 +50,7 @@
@media screen and (max-width: 1024px) {
margin-top: 1.5rem;
padding: 0 1rem;
}
.normal-page__sidebar {
@@ -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="pointer-events-none absolute left-0 right-0 top-0 z-10 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="pointer-events-none absolute bottom-0 left-0 right-0 z-10 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,487 @@
<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 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
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(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, timeOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { 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(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, endOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { 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,219 @@
<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),
})
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 + (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,232 @@
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,
staleTime: 0,
})
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,862 @@
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.unknown)
}
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'
) {
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: 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 formattedValue.replace(/\.?0+$/, '')
}
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 roundedValue.toFixed(fractionDigitCount).replace(/\.?0+$/, '')
}
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,897 @@
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',
},
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({
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,908 @@
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 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[],
): AnalyticsQueryBuilderState {
return {
selectedProjectIds: [...availableProjectIds],
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(availableProjectIds),
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[],
): boolean {
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
const areDefaultProjectsSelected =
availableProjectIds.length === 0
? state.selectedProjectIds.length === 0
: areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
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[],
): AnalyticsQueryBuilderState {
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS])
const selectedProjectIds =
selectedProjectIdsFromQuery.length > 0
? selectedProjectIdsFromQuery
: defaultState.selectedProjectIds
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,
): MutableRouteQuery {
const nextRouteQuery = {
...currentRouteQuery,
} as MutableRouteQuery
const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
? undefined
: 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,143 @@
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,
})
}
}
if (showProjectVersionProjectColumn) {
nextColumns.push({
key: 'project',
label: formatAnalyticsBreakdownLabel('project', formatMessage),
enableSorting: true,
})
}
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,65 @@
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))
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: (totalSeconds / SECONDS_PER_HOUR).toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
})
}
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,286 @@
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 {
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 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) {
return selectedBreakdowns
.map((breakdown) => displays[breakdown])
.filter((displayValue): displayValue is string => Boolean(displayValue))
.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])
}
}
timeSlices.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,102 @@
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,
)
return downloadSource === ALL_BREAKDOWN_VALUE
? ALL_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()
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.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,131 @@
<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,
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,
})
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,299 @@
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,
): 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(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
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,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
lastAmount: number
lastUnit: AnalyticsLastTimeframeUnit
customStartDate: string
customEndDate: string
nowTimestamp: number
}): 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)
}
}
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,
} = 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,
}),
)
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,156 @@
<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
class="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
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,119 @@
<template>
<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"
:vs-prev-period-percent="card.vsPrevPeriodPercent"
:icon="card.icon"
:active="activeStat === card.key"
:disabled="card.disabled"
@click="setActiveStat(card.key)"
/>
</div>
</template>
<script setup lang="ts">
import { useFormatNumber, useVIntl } from '@modrinth/ui'
import {
type AnalyticsDashboardStat,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
import StatCard from './StatCard.vue'
const {
activeStat,
setActiveStat,
currentTotals,
percentChanges,
hasPreviousPeriodComparison,
selectedBreakdowns,
isAnalyticsDashboardStatRelevant,
} = injectAnalyticsDashboardContext()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const compactNumberFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumSignificantDigits: 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 formatPercent(value: number): string {
const rounded = Math.round(value * 10) / 10
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${rounded.toFixed(1)}%`
}
function formatPreviousPeriodPercent(value: number): string | null {
if (!hasPreviousPeriodComparison.value) {
return null
}
return formatPercent(value)
}
const statCards = computed<
{
key: AnalyticsDashboardStat
label: string
statLabel: string
vsPrevPeriodPercent: string | null
icon: string
disabled: boolean
}[]
>(() => [
{
key: 'views',
label: formatAnalyticsStatLabel('views', formatMessage),
statLabel: formatStatNumber(currentTotals.value.views),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.views),
icon: 'eye',
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
},
{
key: 'downloads',
label: formatAnalyticsStatLabel('downloads', formatMessage),
statLabel: formatStatNumber(currentTotals.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.downloads),
icon: 'download',
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
},
{
key: 'revenue',
label: formatAnalyticsStatLabel('revenue', formatMessage),
statLabel: formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatStatNumber(currentTotals.value.revenue),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.revenue),
icon: 'dollar',
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
},
{
key: 'playtime',
label: formatAnalyticsStatLabel('playtime', formatMessage),
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: formatStatNumber(currentTotals.value.playtime / 3600),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.playtime),
icon: 'clock',
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
},
])
</script>
@@ -0,0 +1,306 @@
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[]>
sanitizeSelectedFilters: (
breakdowns: readonly AnalyticsBreakdownPreset[],
filters: AnalyticsSelectedFilters,
) => AnalyticsSelectedFilters
}
export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
const { queryBuilder, graph, availableProjectIds, 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(),
)
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)
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,495 +0,0 @@
<script setup>
import { useFormatDateTime, useFormatMoney, useFormatNumber } from '@modrinth/ui'
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,
},
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,
},
})
const formatNumber = useFormatNumber()
const formatMoney = useFormatMoney()
const formatDate = useFormatDateTime({
month: 'short',
day: 'numeric',
})
function formatTooltipValue(value, props) {
return props.isMoney ? formatMoney(value) : formatNumber(value)
}
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 ? props.formatLabels(label) : formatDate(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>
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -27,6 +27,11 @@ const messages = defineMessages({
id: 'hosting.plan.select-plan',
defaultMessage: 'Select plan',
},
billedInterval: {
id: 'servers.purchase.step.plan.billed',
defaultMessage:
'billed {interval, select, monthly {monthly} quarterly {quarterly} yearly {yearly} other {{interval}}}',
},
})
const plans: Record<
@@ -132,7 +137,9 @@ const billingMonths = computed(() => {
<span class="m-0 text-2xl font-bold text-contrast">
{{ formatPrice(price / billingMonths, currency, true) }}
<span class="text-lg font-semibold text-secondary">
/ month<template v-if="interval !== 'monthly'">, billed {{ interval }}</template>
/ month<template v-if="interval !== 'monthly'"
>, {{ formatMessage(messages.billedInterval, { interval }) }}</template
>
</span>
</span>
<p class="m-0 max-w-[18rem]">{{ formatMessage(plans[plan].description) }}</p>
@@ -14,7 +14,7 @@
<p class="m-0">
<IntlFormatted
:message-id="messages.resubmitModalDescription"
:message-values="{ projectTitle: project.title }"
:values="{ projectTitle: project.title }"
>
<template #project-title="{ children }">
<span class="font-semibold text-contrast">
+4 -4
View File
@@ -199,7 +199,7 @@ export const scopeMessages = defineMessages({
},
patCreateDescription: {
id: 'scopes.patCreate.description',
defaultMessage: 'Create personal API tokens',
defaultMessage: 'Create personal access tokens',
},
patReadLabel: {
id: 'scopes.patRead.label',
@@ -207,7 +207,7 @@ export const scopeMessages = defineMessages({
},
patReadDescription: {
id: 'scopes.patRead.description',
defaultMessage: 'View created API tokens',
defaultMessage: 'View created personal access tokens',
},
patWriteLabel: {
id: 'scopes.patWrite.label',
@@ -215,7 +215,7 @@ export const scopeMessages = defineMessages({
},
patWriteDescription: {
id: 'scopes.patWrite.description',
defaultMessage: 'Edit personal API tokens',
defaultMessage: 'Edit personal access tokens',
},
patDeleteLabel: {
id: 'scopes.patDelete.label',
@@ -223,7 +223,7 @@ export const scopeMessages = defineMessages({
},
patDeleteDescription: {
id: 'scopes.patDelete.description',
defaultMessage: 'Delete your personal API tokens',
defaultMessage: 'Delete your personal access tokens',
},
sessionReadLabel: {
id: 'scopes.sessionRead.label',
@@ -61,7 +61,7 @@ export interface UseServerInstallContentOptions {
const messages = defineMessages({
unsupportedContentType: {
id: 'discover.install.error.unsupported-content-type',
defaultMessage: 'This content type cannot be installed to a server from browse.',
defaultMessage: 'This content type cannot be installed to a server from Discover.',
},
noServerWorld: {
id: 'discover.install.error.no-server-world',
+15 -1
View File
@@ -467,7 +467,7 @@
<OverflowMenu
v-if="auth.user"
:dropdown-id="`${basePopoutId}-user`"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1 pr-1"
:options="userMenuOptions"
>
<Avatar :src="auth.user.avatar_url" aria-hidden="true" circle />
@@ -1256,6 +1256,10 @@ async function logoutUser() {
}
function runAnalytics() {
if (import.meta.dev) {
return
}
const config = useRuntimeConfig()
const replacedUrl = config.public.apiBaseUrl.replace('v2/', '')
@@ -1386,7 +1390,11 @@ const { cycle: changeTheme } = useTheme()
&-mobile {
.account-container {
opacity: 0;
padding-bottom: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
visibility: hidden;
.account-button {
padding: var(--spacing-card-md);
@@ -1409,6 +1417,12 @@ const { cycle: changeTheme } = useTheme()
&.expanded {
transform: translateY(0);
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.3);
.account-container {
opacity: 1;
pointer-events: auto;
visibility: visible;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More